New Frontend in Vue with drf interface (#72)

* frontend rewrite with vie initial commit

* got ComicCard.vue working nice.

* got TheComicList.vue working.

* added router and basic config

* getting jwt stuff working.

* login with jwt now working.

* implemented browse api call

* implemented browse api recievers

* jwt token is now updating automatically.

* removed code for jwt testing.

* enabled browsing

* breadcrumbs working

* adding django webpack loader

* linking up navigation

* fixes for ComicCard.vue stying

* added thumbnail view

* added thumbnail generation and handling.

* detached breadcrumbs

* fix breadcrumbs

* added first stages of reader

* reader view is working.

* reader is now working with keyboard shortcuts

* implemented setting read page.

* implemented pagination on comic reader.

* hide elements that shouldn't be shown.

* fixed the ComicCard.vue to use as little space as possible.

* fix navbar browse link

* added RecentView.vue and added manual option for breadcrumbs

* updated rest api to handle recent comics.

* most functionality of recent comics done

* modified comicstatus relation to use uuid relation and implemented mark read and unread for batches.

* added functions to TheRecentTable.vue

* added feed link to TheRecentTable.vue

* fixes for comicstatus updates.

* added constraints to comicstatus

* update to python packages.

* some changes for django 4, also removed django-recaptcha2 as it doesnt support django 4.

* some fixes and updates to ComicCard.vue

* cleaned up generate_directory. fixed bug where pages not visible on first call.

* cleaned up generate_directory. fixed bug where pages not visible on first call.

* cleaned up generate_directory. fixed bug where pages not visible on first call.

* cleaned up generate_directory.

* added silk stubs

* fix for re-requesting thumbnail after getting it already.

* fix for removing stale comics.
adding leeway to access token.

* mark read and unread

* added filtering to comic list.

* stored filtering state.

* stored filtering state.

* added next functionality to login.

* cleanup LoginView.vue

* bump font-awesome.

* working on AccountView.vue

* fixed form submission on LoginView.vue

* account page should now be working.

* hide users option if not superuser.

* added pdf support

* make pdf resize.

* added touch controls to pdf reader

* added touch controls to comic reader

* beginnings of routing between issues.

* fixes for navigating pages.

* fixes for navigating pages.

* fixes for navigating pages.

* renamed HomeView.vue to BrowseView.vue

* stubs for users page added. api ready

* users page further functinality

* fix for notification

* fix for notification

* moved messages to parent.

* form to add users

* added error handling

* removed console logging

* classification in base directory should be lowest

* renamed usermisc to classification to be more consistent with what it does.

* renamed usermisc to classification to be more consistent with what it does.

* added functionality to change classification of directories.

* merged rss_id api into account api.

* merged breadcrumbs api into browse api.

* clears some warnings from console.

* fixed read/unread rendering.

* added build script and starting lint

* fixing lint errors

* fixing lint errors

* fixing lint errors

* fixing lint errors

* fixing lint errors

* fixing lint errors

* fixing lint errors

* fixing lint errors

* fixing navigation bugs

* cleanup and fixes

* fixed generated tooltips over calling.

* fixed classifications.

* initial setup now working

* fix navbar branding

* fix favicon

* added beta build script.

* fixes to get ready for production

* optimisations for loading new comics.

* added loading indicators to TheComicList.vue

* lint fixes

* made two methods static. may use them elsewhere.

* fix for scanning files.

* version updates.

* fixes for production

* fixes for production

Co-authored-by: Peter Dwyer <peter.dwyer@clanwilliamhealth.com>
This commit is contained in:
2022-08-25 15:42:20 +01:00
committed by GitHub
parent 3be7d9cb5c
commit c5633bf54a
86 changed files with 25205 additions and 644 deletions

View File

@@ -15,3 +15,4 @@
!requirements.txt !requirements.txt
!package-lock.json !package-lock.json
!package.json !package.json
!frontend

View File

@@ -28,10 +28,5 @@ MEDIA_ROOT='/media'
# Will work without setting if it is in the path # Will work without setting if it is in the path
# UNRAR_TOOL = 'unrar.exe' # UNRAR_TOOL = 'unrar.exe'
# for google recaptcha 2
# DJANGO_CBREADER_USE_RECAPTCHA = True
# DJANGO_RECAPTCHA_PRIVATE_KEY = ''
# DJANGO_RECAPTCHA_PUBLIC_KEY = ''
# Comment the following if not using a reverse proxy. # Comment the following if not using a reverse proxy.
USE_X_FORWARDED_HOST=True USE_X_FORWARDED_HOST=True

3
.gitignore vendored
View File

@@ -93,3 +93,6 @@ db.sqlite3
identifier.sqlite identifier.sqlite
.dmypy.json .dmypy.json
node_modules node_modules
localhost-key.pem
localhost.pem
webpack-stats.json

View File

@@ -20,28 +20,20 @@ RUN apt-add-repository non-free
RUN apt update RUN apt update
COPY requirements.txt /src COPY requirements.txt /src
COPY package.json /src
COPY package-lock.json /src
#RUN apt install -y build-essential postgresql libmariadb-dev libmupdf-dev python3-dev libfreetype-dev libffi-dev libjbig2dec0-dev libjpeg-dev libharfbuzz-dev npm\
# && apt install tini bash unrar python3 mariadb-connector-c jpeg postgresql-libs jbig2dec jpeg openjpeg harfbuzz mupdf postgresql-client\
# && npm install \
# && pip install --upgrade pip \
# && pip install -r requirements.txt \
# && apt remove build-essential postgresql-dev mariadb-dev mariadb-connector-c-dev mupdf-dev python3-dev freetype-dev libffi-dev jbig2dec-dev jpeg-dev openjpeg-dev harfbuzz-dev npm
RUN apt install -y software-properties-common \
&& apt install -y npm cron unrar \
&& npm install \
&& pip install --upgrade pip \
&& pip install -r requirements.txt \
&& apt remove -y npm \
&& apt -y auto-remove
COPY entrypoint.sh /src
COPY . /src/ COPY . /src/
RUN apt install -y npm cron unrar \
&& pip install --upgrade pip \
&& pip install -r requirements.txt \
&& cd frontend \
&& npm install \
&& npm run build \
&& rm -r node_modules \
&& apt -y auto-remove
WORKDIR /src
RUN cat /src/cbreader/crontab >> /etc/cron.daily/cbreader RUN cat /src/cbreader/crontab >> /etc/cron.daily/cbreader
EXPOSE 8000 EXPOSE 8000

4
build-test.ps1 Normal file
View File

@@ -0,0 +1,4 @@
poetry export --without-hashes -f requirements.txt --output requirements.txt
$version=poetry version -s
docker build . -t ajurna/cbwebreader:beta --no-cache
# docker push ajurna/cbwebreader:beta

View File

@@ -1,4 +1,4 @@
poetry export -f requirements.txt --output requirements.txt poetry export --without-hashes -f requirements.txt --output requirements.txt
$version=poetry version -s $version=poetry version -s
docker build . --no-cache -t ajurna/cbwebreader -t ajurna/cbwebreader:$version docker build . --no-cache -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
docker push ajurna/cbwebreader --all-tags docker push ajurna/cbwebreader --all-tags

View File

@@ -4,6 +4,7 @@ Django settings for cbreader project.
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os import os
from datetime import timedelta
from pathlib import Path from pathlib import Path
import dj_database_url import dj_database_url
@@ -21,10 +22,10 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", None)
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', False) == 'True' DEBUG = os.getenv('DJANGO_DEBUG', False) == 'True'
# DEBUG = False
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",") ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")
# Application definition # Application definition
INSTALLED_APPS = ( INSTALLED_APPS = (
@@ -34,7 +35,8 @@ INSTALLED_APPS = (
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"snowpenguin.django.recaptcha2", 'drf_yasg',
'webpack_loader',
'bootstrap4', 'bootstrap4',
"comic", "comic",
"comic_auth", "comic_auth",
@@ -42,37 +44,28 @@ INSTALLED_APPS = (
'imagekit', 'imagekit',
'django_boost', 'django_boost',
'sri', 'sri',
"corsheaders",
'django_filters',
'rest_framework',
# 'silk'
) )
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django_permissions_policy.PermissionsPolicyMiddleware", "django_permissions_policy.PermissionsPolicyMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
# 'silk.middleware.SilkyMiddleware',
'csp.middleware.CSPMiddleware', 'csp.middleware.CSPMiddleware',
] ]
ROOT_URLCONF = "cbreader.urls" ROOT_URLCONF = "cbreader.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": ["templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
WSGI_APPLICATION = "cbreader.wsgi.application" WSGI_APPLICATION = "cbreader.wsgi.application"
@@ -109,7 +102,9 @@ STATIC_URL = "/static/"
STATICFILES_DIRS = [ STATICFILES_DIRS = [
Path(BASE_DIR, "static"), Path(BASE_DIR, "static"),
Path(BASE_DIR, "node_modules") # Path(BASE_DIR, "node_modules"),
Path(BASE_DIR, "frontend", "node_modules"),
Path(BASE_DIR, "frontend", "dist")
] ]
STATIC_ROOT = os.getenv('STATIC_ROOT', None) STATIC_ROOT = os.getenv('STATIC_ROOT', None)
@@ -125,9 +120,6 @@ LOGIN_URL = "/login/"
UNRAR_TOOL = os.getenv("DJANGO_UNRAR_TOOL", None) UNRAR_TOOL = os.getenv("DJANGO_UNRAR_TOOL", None)
CBREADER_USE_RECAPTCHA = os.getenv("DJANGO_CBREADER_USE_RECAPTCHA", False)
RECAPTCHA_PRIVATE_KEY = os.getenv("DJANGO_RECAPTCHA_PRIVATE_KEY", '')
RECAPTCHA_PUBLIC_KEY = os.getenv("DJANGO_RECAPTCHA_PUBLIC_KEY", '')
COMIC_BOOK_VOLUME = Path(os.getenv("COMIC_BOOK_VOLUME")) COMIC_BOOK_VOLUME = Path(os.getenv("COMIC_BOOK_VOLUME"))
@@ -151,10 +143,10 @@ BOOTSTRAP4 = {
}, },
} }
CSP_DEFAULT_SRC = ("'none'",) CSP_DEFAULT_SRC = ("'none'",)
CSP_STYLE_SRC = ("'self'",) CSP_STYLE_SRC = ("'self'", "'sha256-MBVp6JYxbC/wICelYC6eULCRpgi9kGezXXSaq/TS2+I='")
CSP_IMG_SRC = ("'self'", "data:") CSP_IMG_SRC = ("'self'", "data:")
CSP_FONT_SRC = ("'self'",) CSP_FONT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "'sha256-khnq7MWUoC3fJlH98ZjaCbVOvyd5+vnfVyue/ca55JA='") CSP_SCRIPT_SRC = ("'self'",)
CSP_CONNECT_SRC = ("'self'",) CSP_CONNECT_SRC = ("'self'",)
CSP_INCLUDE_NONCE_IN = ['script-src'] CSP_INCLUDE_NONCE_IN = ['script-src']
CSP_SCRIPT_SRC_ATTR = ("'self'",)# "'unsafe-inline'") CSP_SCRIPT_SRC_ATTR = ("'self'",)# "'unsafe-inline'")
@@ -177,10 +169,78 @@ PERMISSIONS_POLICY = {
"usb": [], "usb": [],
} }
SESSION_COOKIE_HTTPONLY = True # SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True # SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Strict' # SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_HTTPONLY = True # CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SECURE = True # CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Strict' # CSRF_COOKIE_SAMESITE = 'Strict'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
)
}
CORS_ALLOW_ALL_ORIGINS = True
SIMPLE_JWT = {
"ROTATE_REFRESH_TOKENS": True,
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
'LEEWAY': timedelta(minutes=5),
}
TEMPLATES_DIR = os.path.join(BASE_DIR, 'templates')
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [TEMPLATES_DIR, ],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
WEBPACK_LOADER = {
'DEFAULT': {
'CACHE': not DEBUG,
'BUNDLE_DIR_NAME': '/bundles/', # must end with slash
'STATS_FILE': os.path.join(FRONTEND_DIR, 'webpack-stats.json'),
'INTEGRITY': True,
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 9,
}
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
SUPPORTED_FILES = [".rar", ".zip", ".cbr", ".cbz", ".pdf"]

View File

@@ -14,21 +14,71 @@ Including another URLconf
2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls))
""" """
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include
from django.conf.urls.static import static 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 rest_framework import routers, permissions
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from rest_framework_extensions.routers import ExtendedDefaultRouter
from django.views.generic import TemplateView
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
schema_view = get_schema_view(
openapi.Info(
title="CBWebReader API",
default_version='v1',
description="API to access your comic collection",
contact=openapi.Contact(name="Ajurna", url="https://github.com/ajurna/cbwebreader"),
license=openapi.License(name="MIT License"),
),
public=True,
permission_classes=[permissions.AllowAny]
)
import comic.views import comic.views
import comic_auth.views import comic_auth.views
from comic import rest, feeds
router = ExtendedDefaultRouter()
router.register(r'users', rest.UserViewSet)
# router.register(r'usermisc', rest.UserMiscViewSet)
# router.register(r'groups', rest.GroupViewSet)
router.register(r'browse', rest.BrowseViewSet, basename='browse')
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
router.register(r'read', rest.ReadViewSet, basename='read')\
.register(r'image', rest.ImageViewSet, basename='image', parents_query_lookups=['selector'])
router.register(r'recent', rest.RecentComicsView, basename="recent")
router.register(r'action', rest.ActionViewSet, basename='action')
router.register(r'account', rest.AccountViewSet, basename='account')
router.register(r'directory', rest.DirectoryViewSet, basename='directory')
router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup')
urlpatterns = [ urlpatterns = [
url(r"^$", comic.views.comic_redirect), # url(r"^$", comic.views.comic_redirect),
url(r"^login/", comic_auth.views.comic_login), # url(r"^login/", comic_auth.views.comic_login),
url(r"^logout/", comic_auth.views.comic_logout), # url(r"^logout/", comic_auth.views.comic_logout),
url(r"^setup/", comic.views.initial_setup), # url(r"^setup/", comic.views.initial_setup),
url(r"^comic/", include("comic.urls")), # url(r"^comic/", include("comic.urls")),
url(r"^admin/", admin.site.urls), path('admin/', admin.site.urls),
path("feed/<user_selector>/", feeds.RecentComicsAPI()),
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/', include(router.urls)),
path("",
TemplateView.as_view(template_name="application.html"),
name="app",
),
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))]

View File

@@ -23,6 +23,7 @@ class ComicBookAdmin(admin.ModelAdmin):
) )
list_filter = ('date_added',) list_filter = ('date_added',)
raw_id_fields = ('directory',) raw_id_fields = ('directory',)
search_fields = ['file_name']
@admin.register(ComicPage) @admin.register(ComicPage)

View File

@@ -44,3 +44,37 @@ class RecentComics(Feed):
# item_link is only needed if NewsItem has no get_absolute_url method. # item_link is only needed if NewsItem has no get_absolute_url method.
def item_link(self, item: ComicBook) -> str: def item_link(self, item: ComicBook) -> str:
return reverse('read_comic', args=(urlsafe_base64_encode(item.selector.bytes),)) return reverse('read_comic', args=(urlsafe_base64_encode(item.selector.bytes),))
class RecentComicsAPI(Feed):
title = "CBWebReader Recent Comics"
link = "/read/"
description = "Recently added Comics"
user: User
def get_object(self, request: HttpRequest, user_selector: str, *args, **kwargs) -> UserMisc:
user_misc = get_object_or_404(UserMisc, feed_id=user_selector)
self.user = user_misc.user
return user_misc.user
def items(self) -> ComicBook:
comics = ComicBook.objects.order_by("-date_added")
comics = comics.annotate(
classification=Case(
When(directory__isnull=True, then=Directory.Classification.C_18),
default=F('directory__classification'),
output_field=PositiveSmallIntegerField(choices=Directory.Classification.choices)
)
)
comics = comics.filter(classification__lte=self.user.usermisc.allowed_to_read)
return comics[:10]
def item_title(self, item: ComicBook) -> str:
return item.file_name
def item_description(self, item: ComicBook) -> str:
return item.date_added.strftime("%a, %e %b %Y %H:%M")
# item_link is only needed if NewsItem has no get_absolute_url method.
def item_link(self, item: ComicBook) -> str:
return f'#/read/{item.selector}/'

View File

@@ -74,6 +74,8 @@ class Command(BaseCommand):
next_directory.save() next_directory.save()
self.scan_directory(next_directory) self.scan_directory(next_directory)
else: else:
if file.suffix.lower() not in settings.SUPPORTED_FILES:
continue
if self.OUTPUT: if self.OUTPUT:
logger.info(f"Scanning File {file}") logger.info(f"Scanning File {file}")
try: try:

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-07-07 16:03
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('comic', '0028_alter_comicpage_options'),
]
operations = [
migrations.AddField(
model_name='comicbook',
name='directory2',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='directory2', to='comic.directory', to_field='selector'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.2.14 on 2022-07-07 16:20
from django.db import migrations
def forwards_func(apps, schema_editor):
ComicBook = apps.get_model("comic", "ComicBook")
for comic in ComicBook.objects.all():
if comic.directory:
comic.directory2 = comic.directory
comic.save()
class Migration(migrations.Migration):
dependencies = [
('comic', '0029_comicbook_directory2'),
]
operations = [
migrations.RunPython(forwards_func),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.14 on 2022-07-07 16:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comic', '0030_auto_20220707_1720'),
]
operations = [
migrations.RemoveField(
model_name='comicbook',
name='directory',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.14 on 2022-07-07 16:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comic', '0031_remove_comicbook_directory'),
]
operations = [
migrations.RenameField(
model_name='comicbook',
old_name='directory2',
new_name='directory',
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-07-07 16:32
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('comic', '0032_rename_directory2_comicbook_directory'),
]
operations = [
migrations.AlterField(
model_name='comicbook',
name='directory',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comic.directory', to_field='selector'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-07-08 08:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('comic', '0033_alter_comicbook_directory'),
]
operations = [
migrations.AddField(
model_name='directory',
name='parent2',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_new', to='comic.directory', to_field='selector'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.2.14 on 2022-07-08 08:10
from django.db import migrations
def forwards_func(apps, schema_editor):
Directory = apps.get_model("comic", "Directory")
for directory in Directory.objects.all():
if directory.parent:
directory.parent2 = directory.parent
directory.save()
def backwards_func(apps, schema_editor):
return
Directory = apps.get_model("comic", "Directory")
for directory in Directory.objects.all():
if directory.parent:
directory.parent2 = directory.parent
directory.save()
class Migration(migrations.Migration):
dependencies = [
('comic', '0034_directory_parent2'),
]
operations = [
migrations.RunPython(forwards_func, backwards_func),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.14 on 2022-07-08 08:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comic', '0035_auto_20220708_0910'),
]
operations = [
migrations.RemoveField(
model_name='directory',
name='parent',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.14 on 2022-07-08 08:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comic', '0036_remove_directory_parent'),
]
operations = [
migrations.RenameField(
model_name='directory',
old_name='parent2',
new_name='parent',
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-07-08 08:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('comic', '0037_rename_parent2_directory_parent'),
]
operations = [
migrations.AlterField(
model_name='directory',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comic.directory', to_field='selector'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-07-21 10:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('comic', '0038_alter_directory_parent'),
]
operations = [
migrations.AddField(
model_name='comicstatus',
name='comic2',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comic2', to='comic.comicbook', to_field='selector'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.2.14 on 2022-07-21 10:21
from django.db import migrations
def forwards_func(apps, schema_editor):
ComicStatus = apps.get_model("comic", "ComicStatus")
for status in ComicStatus.objects.all():
status.comic2 = status.comic
status.save()
def backwards_func(apps, schema_editor):
ComicStatus = apps.get_model("comic", "ComicStatus")
for status in ComicStatus.objects.all():
status.comic = status.comic2
status.save()
class Migration(migrations.Migration):
dependencies = [
('comic', '0039_comicstatus_comic2'),
]
operations = [
migrations.RunPython(forwards_func, backwards_func),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-07-21 10:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('comic', '0040_auto_20220721_1126'),
]
operations = [
migrations.AlterField(
model_name='comicstatus',
name='comic2',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comic2', to='comic.comicbook', to_field='selector'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.14 on 2022-07-21 10:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comic', '0041_alter_comicstatus_comic2'),
]
operations = [
migrations.RemoveField(
model_name='comicstatus',
name='comic',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.14 on 2022-07-21 10:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comic', '0042_remove_comicstatus_comic'),
]
operations = [
migrations.RenameField(
model_name='comicstatus',
old_name='comic2',
new_name='comic',
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-07-21 10:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('comic', '0043_rename_comic2_comicstatus_comic'),
]
operations = [
migrations.AlterField(
model_name='comicstatus',
name='comic',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='comic.comicbook', to_field='selector'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.14 on 2022-07-22 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comic', '0044_alter_comicstatus_comic'),
]
operations = [
migrations.AddConstraint(
model_name='comicstatus',
constraint=models.UniqueConstraint(fields=('user', 'comic'), name='one_per_user_per_comic'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.0.6 on 2022-07-22 11:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comic', '0045_comicstatus_one_per_user_per_comic'),
]
operations = [
migrations.AddConstraint(
model_name='comicbook',
constraint=models.UniqueConstraint(fields=('directory', 'file_name'), name='one_comic_name_per_directory'),
),
]

View File

@@ -14,6 +14,7 @@ from django.conf import settings
from django.contrib.auth.models import User, AbstractUser from django.contrib.auth.models import User, AbstractUser
from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db import models from django.db import models
from django.db.models import UniqueConstraint
from django.db.transaction import atomic from django.db.transaction import atomic
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
@@ -36,7 +37,7 @@ class Directory(models.Model):
C_18 = 4, '18' C_18 = 4, '18'
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
parent = models.ForeignKey("Directory", null=True, blank=True, on_delete=models.CASCADE) parent = models.ForeignKey("Directory", null=True, blank=True, on_delete=models.CASCADE, to_field="selector")
selector = models.UUIDField(unique=True, default=uuid.uuid4, db_index=True) selector = models.UUIDField(unique=True, default=uuid.uuid4, db_index=True)
thumbnail = ProcessedImageField(upload_to='thumbs', thumbnail = ProcessedImageField(upload_to='thumbs',
processors=[ResizeToFill(200, 300)], processors=[ResizeToFill(200, 300)],
@@ -55,6 +56,14 @@ class Directory(models.Model):
def __str__(self): def __str__(self):
return "Directory: {0}; {1}".format(self.name, self.parent) return "Directory: {0}; {1}".format(self.name, self.parent)
@property
def title(self):
return self.name
@property
def type(self):
return 'Directory'
def mark_read(self, user): def mark_read(self, user):
books = ComicBook.objects.filter(directory=self) books = ComicBook.objects.filter(directory=self)
for book in books: for book in books:
@@ -124,7 +133,7 @@ class Directory(models.Model):
class ComicBook(models.Model): class ComicBook(models.Model):
file_name = models.TextField() file_name = models.TextField()
date_added = models.DateTimeField(auto_now_add=True) date_added = models.DateTimeField(auto_now_add=True)
directory = models.ForeignKey(Directory, blank=True, null=True, on_delete=models.CASCADE) directory = models.ForeignKey(Directory, blank=True, null=True, on_delete=models.CASCADE, to_field="selector")
selector = models.UUIDField(unique=True, default=uuid.uuid4, db_index=True) selector = models.UUIDField(unique=True, default=uuid.uuid4, db_index=True)
version = models.IntegerField(default=1) version = models.IntegerField(default=1)
thumbnail = ProcessedImageField(upload_to='thumbs', thumbnail = ProcessedImageField(upload_to='thumbs',
@@ -134,9 +143,22 @@ class ComicBook(models.Model):
null=True) null=True)
thumbnail_index = models.PositiveIntegerField(default=0) thumbnail_index = models.PositiveIntegerField(default=0)
class Meta:
constraints = [
UniqueConstraint(fields=['directory', 'file_name'], name='one_comic_name_per_directory')
]
def __str__(self): def __str__(self):
return self.file_name return self.file_name
@property
def title(self):
return self.file_name
@property
def type(self):
return 'ComicBook'
def mark_read(self, user: User): def mark_read(self, user: User):
status, _ = ComicStatus.objects.get_or_create(comic=self, user=user) status, _ = ComicStatus.objects.get_or_create(comic=self, user=user)
status.mark_read() status.mark_read()
@@ -158,7 +180,10 @@ class ComicBook(models.Model):
def get_pdf(self) -> Path: def get_pdf(self) -> Path:
base_dir = settings.COMIC_BOOK_VOLUME base_dir = settings.COMIC_BOOK_VOLUME
if self.directory:
return Path(base_dir, self.directory.get_path(), self.file_name) return Path(base_dir, self.directory.get_path(), self.file_name)
else:
return Path(base_dir, self.file_name)
def get_image(self, page: int): def get_image(self, page: int):
base_dir = settings.COMIC_BOOK_VOLUME base_dir = settings.COMIC_BOOK_VOLUME
@@ -468,11 +493,16 @@ class ComicPage(models.Model):
class ComicStatus(models.Model): class ComicStatus(models.Model):
user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE) user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE)
comic = models.ForeignKey(ComicBook, unique=False, null=False, on_delete=models.CASCADE) comic = models.ForeignKey(ComicBook, unique=False, blank=False, null=False, on_delete=models.CASCADE, to_field="selector")
last_read_page = models.IntegerField(default=0) last_read_page = models.IntegerField(default=0)
unread = models.BooleanField(default=True) unread = models.BooleanField(default=True)
finished = models.BooleanField(default=False) finished = models.BooleanField(default=False)
class Meta:
constraints = [
UniqueConstraint(fields=['user', 'comic'], name='one_per_user_per_comic')
]
def mark_read(self): def mark_read(self):
page_count = ComicPage.objects.filter(Comic=self.comic).count() page_count = ComicPage.objects.filter(Comic=self.comic).count()
self.unread = False self.unread = False

758
comic/rest.py Normal file
View File

@@ -0,0 +1,758 @@
import mimetypes
from itertools import chain
from pathlib import Path
from typing import Union, NamedTuple, List
from uuid import UUID
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db.models import Count, Case, When, F, PositiveSmallIntegerField, Q
from django.http import FileResponse
from drf_yasg.utils import swagger_auto_schema
from rest_framework import viewsets, serializers, mixins, permissions, status, renderers
from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request
from rest_framework.response import Response
from comic import models
from comic.errors import NotCompatibleArchive
from comic.util import generate_breadcrumbs_from_path
class UserSerializer(serializers.ModelSerializer):
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
source='usermisc')
class Meta:
model = User
fields = ['id', 'username', 'email', 'is_superuser', 'classification']
class AdminPasswordResetSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(required=False)
class ClassificationSerializer(serializers.Serializer):
classification = serializers.IntegerField()
def validate_classification(self, data):
if data in models.Directory.Classification:
return data
raise serializers.ValidationError('Invalid Classification sent.')
class UserViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows users to be viewed or edited.
"""
queryset = User.objects.all().order_by('username')
serializer_class = UserSerializer
permission_classes = [permissions.IsAdminUser]
@action(methods=['patch'], detail=True, serializer_class=AdminPasswordResetSerializer)
def reset_password(self, request: Request, pk: int) -> Response:
"""
This will return a new password set on the user.
"""
target_user = get_object_or_404(User, id=pk)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
if target_user.username == serializer.data['username']:
password = User.objects.make_random_password()
target_user.set_password(password)
resp_serializer = self.get_serializer({
'username': target_user.username,
'password': password
})
return Response(resp_serializer.data)
return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST)
@action(methods=['patch'], detail=True, serializer_class=ClassificationSerializer)
def set_classification(self, request: Request, pk: int) -> Response:
"""
API Endpoint that will set the classification on the specified user.
"""
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
misc, _ = models.UserMisc.objects.get_or_create(user_id=pk)
misc.allowed_to_read = serializer.data['classification']
misc.save()
return Response(data={'classification': misc.allowed_to_read})
else:
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
class UserMiscSerializer(serializers.ModelSerializer):
class Meta:
model = models.UserMisc
fields = ['user', 'feed_id', 'allowed_to_read']
class UserMiscViewSet(viewsets.ModelViewSet):
queryset = models.UserMisc.objects.all()
serializer_class = UserMiscSerializer
permission_classes = [permissions.IsAdminUser]
class GroupSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Group
fields = ['url', 'name']
class GroupViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows groups to be viewed or edited.
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [permissions.IsAuthenticated]
class BrowseFileField(serializers.FileField):
def to_representation(self, value):
if not value:
return None
return Path(settings.MEDIA_URL, value.name).as_posix()
class BrowseSerializer(serializers.Serializer):
selector = serializers.UUIDField()
title = serializers.CharField()
progress = serializers.IntegerField()
total = serializers.IntegerField()
type = serializers.CharField()
thumbnail = BrowseFileField()
classification = serializers.IntegerField()
finished = serializers.BooleanField()
unread = serializers.BooleanField()
class BreadcrumbSerializer(serializers.Serializer):
id = serializers.IntegerField()
selector = serializers.UUIDField()
name = serializers.CharField()
class ArchiveFile(NamedTuple):
file_name: str
mime_type: str
class BrowseViewSet(viewsets.GenericViewSet):
serializer_class = BrowseSerializer
permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector'
def list(self, request):
serializer = self.get_serializer(self.generate_directory(request.user), many=True)
return Response(serializer.data)
def retrieve(self, request, selector: UUID):
directory = models.Directory.objects.get(selector=selector)
serializer = self.get_serializer(self.generate_directory(request.user, directory), many=True)
return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: BreadcrumbSerializer(many=True)})
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
queryset = []
comic = False
try:
directory = models.Directory.objects.get(selector=selector)
except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector)
directory = comic.directory
for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
queryset.append({
"id": index,
"selector": item.selector,
"name": item.name,
})
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@staticmethod
def clean_directories(directories, dir_path, directory=None):
dir_db_set = set([Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories])
dir_list = set([x for x in sorted(dir_path.glob('*')) if x.is_dir()])
# Create new directories db instances
for new_directory in dir_list - dir_db_set:
models.Directory(name=new_directory.name, parent=directory).save()
# Remove stale db instances
for stale_directory in dir_db_set - dir_list:
models.Directory.objects.get(name=stale_directory.name, parent=directory).delete()
@staticmethod
def get_archive_files(archive) -> List[ArchiveFile]:
return [
ArchiveFile(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
if not x.endswith('/') and mimetypes.guess_type(x)[0]
]
@staticmethod
def clean_files(files, user, dir_path, directory=None):
file_list = set([x for x in sorted(dir_path.glob('*')) if x.is_file()])
files_db_set = set([Path(dir_path, x.file_name) for x in files])
# Parse new comics
books_to_add = []
for new_comic in file_list - files_db_set:
if new_comic.suffix.lower() in settings.SUPPORTED_FILES:
books_to_add.append(
models.ComicBook(file_name=new_comic.name, directory=directory)
)
models.ComicBook.objects.bulk_create(books_to_add)
pages_to_add = []
status_to_add = []
for book in books_to_add:
status_to_add.append(models.ComicStatus(user=user, comic=book))
try:
archive, archive_type = book.get_archive()
if archive_type == 'archive':
pages_to_add.extend([
models.ComicPage(
Comic=book, index=idx, page_file_name=page.file_name, content_type=page.mime_type
) for idx, page in enumerate(BrowseViewSet.get_archive_files(archive))
])
elif archive_type == 'pdf':
pages_to_add.extend([
models.ComicPage(
Comic=book, index=idx, page_file_name=idx + 1, content_type='application/pdf'
) for idx in range(archive.page_count)
])
except NotCompatibleArchive:
pass
models.ComicStatus.objects.bulk_create(status_to_add)
models.ComicPage.objects.bulk_create(pages_to_add)
# Remove stale comic instances
for stale_comic in files_db_set - file_list:
models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete()
def generate_directory(self, user: User, directory=None):
"""
:type user: User
:type directory: Directory
"""
dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
files = []
dir_db_query = models.Directory.objects.filter(parent=directory)
self.clean_directories(dir_db_query, dir_path, directory)
file_db_query = models.ComicBook.objects.filter(directory=directory)
self.clean_files(file_db_query, user, dir_path, directory)
dir_db_query = dir_db_query.annotate(
total=Count('comicbook', distinct=True),
progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True,
comicbook__comicstatus__user=user), distinct=True),
finished=Q(total=F('progress')),
unread=Q(total__gt=F('progress'))
)
files.extend(dir_db_query)
# Create Missing Status
new_status = [models.ComicStatus(comic=file, user=user) for file in
file_db_query.exclude(comicstatus__in=models.ComicStatus.objects.filter(
comic__in=file_db_query, user=user))]
models.ComicStatus.objects.bulk_create(new_status)
file_db_query = file_db_query.annotate(
total=Count('comicpage', distinct=True),
progress=F('comicstatus__last_read_page') + 1,
finished=F('comicstatus__finished'),
unread=F('comicstatus__unread'),
user=F('comicstatus__user'),
classification=Case(
When(directory__isnull=True, then=models.Directory.Classification.C_G),
default=F('directory__classification'),
output_field=PositiveSmallIntegerField(choices=models.Directory.Classification.choices)
)
).filter(Q(user__isnull=True) | Q(user=user.id))
files.extend(file_db_query)
for file in chain(file_db_query, dir_db_query):
if file.thumbnail and not Path(file.thumbnail.path).exists():
file.thumbnail.delete()
file.save()
files.sort(key=lambda x: x.title)
files.sort(key=lambda x: x.type, reverse=True)
return files
class GenerateThumbnailSerializer(serializers.Serializer):
selector = serializers.UUIDField()
thumbnail = BrowseFileField()
class GenerateThumbnailViewSet(viewsets.ViewSet):
permission_classes = [permissions.IsAuthenticated]
serializer_class = GenerateThumbnailSerializer
lookup_field = 'selector'
def retrieve(self, _request, selector: UUID):
try:
directory = models.Directory.objects.get(selector=selector)
if not directory.thumbnail:
directory.generate_thumbnail()
return Response(
self.serializer_class({
"selector": directory.selector,
"thumbnail": directory.thumbnail
}).data
)
except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector)
if not comic.thumbnail:
comic.generate_thumbnail()
return Response(
self.serializer_class({
"selector": comic.selector,
"thumbnail": comic.thumbnail
}).data
)
class PageSerializer(serializers.Serializer):
index = serializers.IntegerField()
page_file_name = serializers.CharField()
content_type = serializers.CharField()
class DirectionSerializer(serializers.Serializer):
route = serializers.ChoiceField(choices=['read', 'browse'])
selector = serializers.UUIDField(required=False)
class ReadSerializer(serializers.Serializer):
selector = serializers.UUIDField()
title = serializers.CharField()
last_read_page = serializers.IntegerField()
prev_comic = DirectionSerializer()
next_comic = DirectionSerializer()
pages = PageSerializer(many=True)
class TypeSerializer(serializers.Serializer):
type = serializers.CharField()
class ReadPageSerializer(serializers.Serializer):
page = serializers.IntegerField(source='last_read_page')
class ReadViewSet(viewsets.GenericViewSet):
serializer_class = ReadSerializer
permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector'
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer()})
def retrieve(self, request: Request, selector: UUID) -> Response:
comic = get_object_or_404(models.ComicBook, selector=selector)
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
pages = models.ComicPage.objects.filter(Comic=comic)
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name'))
comic_index = comic_list.index(comic)
try:
prev_comic = {'route': 'browse', 'selector': comic.directory.selector} if comic_index == 0 else \
{'route': 'read', 'selector': comic_list[comic_index-1].selector}
except AttributeError:
prev_comic = {'route': 'browse'}
try:
next_comic = {'route': 'browse', 'selector': comic.directory.selector} if comic_index+1 == len(comic_list) \
else {'route': 'read', 'selector': comic_list[comic_index+1].selector}
except AttributeError:
next_comic = {'route': 'browse'}
data = {
"selector": comic.selector,
"title": comic.file_name,
"last_read_page": comic_status.last_read_page,
"prev_comic": prev_comic,
"next_comic": next_comic,
"pages": pages,
}
serializer = self.serializer_class(data)
return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
@action(methods=['get'], detail=True)
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response]:
book = models.ComicBook.objects.get(selector=selector)
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
try:
if book.directory.classification > misc.allowed_to_read:
return Response(status=400, data={'errors': 'Not allowed to read.'})
except AttributeError:
pass
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
@action(methods=['get'], detail=True)
def type(self, _request: Request, selector: UUID) -> Response:
book = models.ComicBook.objects.get(selector=selector)
return Response({'type': book.file_name.split('.')[-1].lower()})
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer)
@action(methods=['put'], detail=True, serializer_class=ReadPageSerializer)
def set_page(self, request: Request, selector: UUID) -> Response:
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
comic_status, _ = models.ComicStatus.objects.annotate(page_count=Count('comic__comicpage'))\
.get_or_create(comic_id=selector, user=request.user)
comic_status.last_read_page = serializer.data['page']
comic_status.unread = False
if comic_status.page_count-1 == comic_status.last_read_page:
comic_status.finished = True
else:
comic_status.finished = False
comic_status.save()
return Response(self.get_serializer(comic_status).data)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class PassthroughRenderer(renderers.BaseRenderer):
"""
Return data as-is. View should supply a Response.
"""
media_type = '*/*'
format = ''
def render(self, data, accepted_media_type=None, renderer_context=None):
return data
class ImageViewSet(viewsets.ViewSet):
queryset = models.ComicPage.objects.all()
lookup_field = 'page'
renderer_classes = [PassthroughRenderer]
def retrieve(self, _request, parent_lookup_selector, page):
book = models.ComicBook.objects.get(selector=parent_lookup_selector)
img, content = book.get_image(int(page))
self.renderer_classes[0].media_type = content
response = FileResponse(img, content_type=content)
return response
class StandardResultsSetPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
class RecentComicsSerializer(serializers.ModelSerializer):
total_pages = serializers.IntegerField()
unread = serializers.BooleanField()
finished = serializers.BooleanField()
last_read_page = serializers.IntegerField()
class Meta:
model = models.ComicBook
fields = ['file_name', 'date_added', 'selector', 'total_pages', 'unread', 'finished', 'last_read_page']
class RecentComicsView(mixins.ListModelMixin, viewsets.GenericViewSet):
queryset = models.ComicBook.objects.all()
serializer_class = RecentComicsSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
if "search_text" in self.request.query_params:
query = models.ComicBook.objects.filter(file_name__icontains=self.request.query_params["search_text"])
else:
query = models.ComicBook.objects.all()
query = query.annotate(
total_pages=Count('comicpage'),
unread=Case(When(comicstatus__user=user, then='comicstatus__unread')),
finished=Case(When(comicstatus__user=user, then='comicstatus__finished')),
last_read_page=Case(When(comicstatus__user=user, then='comicstatus__last_read_page')) + 1,
classification=Case(
When(directory__isnull=True, then=models.Directory.Classification.C_18),
default=F('directory__classification'),
output_field=PositiveSmallIntegerField(choices=models.Directory.Classification.choices)
)
)
query = query.order_by('-date_added')
return query
class ActionSerializer(serializers.Serializer):
selectors = serializers.ListSerializer(child=serializers.UUIDField())
class ActionViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated]
lookup_field = 'action'
serializer_class = ActionSerializer
@action(detail=False, methods=['PUT'])
def mark_read(self, request):
serializer = ActionSerializer(data=request.data)
if serializer.is_valid():
comics = self.get_comics(serializer.data['selectors'])
comic_status = models.ComicStatus.objects.filter(comic__selector__in=comics, user=request.user)
comic_status = comic_status.annotate(total_pages=Count('comic__comicpage'))
status_to_update = []
for c_status in comic_status:
c_status.last_read_page = c_status.total_pages-1
c_status.unread = False
c_status.finished = True
status_to_update.append(c_status)
comics.remove(str(c_status.comic_id))
for new_status in comics:
comic = models.ComicBook.objects.annotate(
total_pages=Count('comicpage')).get(selector=new_status)
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
obj.unread = False
obj.finished = True
obj.last_read_page = comic.total_pages
status_to_update.append(obj)
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
return Response({'status': 'marked_read'})
else:
return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['PUT'])
def mark_unread(self, request):
serializer = ActionSerializer(data=request.data)
if serializer.is_valid():
serializer = ActionSerializer(data=request.data)
if serializer.is_valid():
comics = self.get_comics(serializer.data['selectors'])
comic_status = models.ComicStatus.objects.filter(comic__selector__in=comics, user=request.user)
status_to_update = []
for c_status in comic_status:
c_status.last_read_page = 0
c_status.unread = True
c_status.finished = False
status_to_update.append(c_status)
comics.remove(str(c_status.comic_id))
for new_status in comics:
comic = models.ComicBook.objects.get(selector=new_status)
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
obj.unread = True
obj.finished = False
obj.last_read_page = 0
status_to_update.append(obj)
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
return Response({'status': 'marked_unread'})
else:
return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
def get_comics(self, selectors):
data = set()
data = data.union(
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
directories = models.Directory.objects.filter(selector__in=selectors)
if directories:
for directory in directories:
data = data.union(
set(models.ComicBook.objects.filter(directory=directory).values_list('selector', flat=True)))
data = data.union(self.get_comics(models.Directory.objects.filter(
parent__in=directories).values_list('selector', flat=True)))
return [str(x) for x in data]
class RSSSerializer(serializers.Serializer):
feed_id = serializers.UUIDField()
class AccountSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['username', 'email', 'is_superuser']
class UpdateEmailSerializer(serializers.Serializer):
username = serializers.CharField()
email = serializers.EmailField()
password = serializers.CharField()
class PasswordResetSerializer(serializers.Serializer):
username = serializers.CharField()
old_password = serializers.CharField()
new_password = serializers.CharField(required=False)
new_password_confirm = serializers.CharField(required=False)
def validate_new_password(self, data):
if data == '':
return data
try:
validate_password(data)
except ValidationError as e:
raise serializers.ValidationError(e)
return data
def validate(self, attrs):
super().validate(attrs)
if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError('New passwords do not match')
return attrs
class AccountViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated]
lookup_field = 'username'
serializer_class = AccountSerializer
@swagger_auto_schema(responses={200: AccountSerializer()})
@action(detail=False, methods=['PATCH'], serializer_class=PasswordResetSerializer)
def reset_password(self, request: Request) -> Response:
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
if request.user.username != serializer.data['username']:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Username does not match account'})
if not request.user.check_password(serializer.data['old_password']):
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Password Incorrect'})
request.user.set_password(serializer.data['new_password'])
request.user.save()
return Response(AccountSerializer(request.user).data)
else:
return Response({"errors": serializer.errors}, status.HTTP_400_BAD_REQUEST)
def list(self, request):
serializer = self.get_serializer(request.user)
return Response(serializer.data)
@swagger_auto_schema(responses={200: AccountSerializer()})
@action(detail=False, methods=['PATCH'], serializer_class=UpdateEmailSerializer)
def update_email(self, request: Request) -> Response:
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
if request.user.username != serializer.data['username']:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Username does not match account'})
if not request.user.check_password(serializer.data['password']):
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Password Incorrect'})
request.user.email = serializer.data['email']
request.user.save()
account = AccountSerializer(request.user)
return Response(account.data)
else:
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(responses={status.HTTP_200_OK: RSSSerializer()})
@action(methods=['get'], detail=False, serializer_class=RSSSerializer)
def feed_id(self, request: Request):
"""
Return the RSS feed id needed to get users RSS Feed.
"""
user_misc = get_object_or_404(models.UserMisc, user=request.user)
serializer = self.get_serializer(user_misc)
return Response(serializer.data)
class DirectorySerializer(serializers.ModelSerializer):
class Meta:
model = models.Directory
fields = ['selector', 'classification']
extra_kwargs = {
'selector': {'validators': []},
}
class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
serializer_class = DirectorySerializer
queryset = models.Directory.objects.all()
permission_classes = [permissions.IsAdminUser]
lookup_field = 'selector'
@swagger_auto_schema(responses={200: DirectorySerializer(many=True)})
def update(self, request: Request, selector: UUID) -> Response:
"""
This will set the classification of a directory and all it's children.
"""
main_parent = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
main_parent.classification = serializer.data['classification']
to_update = {main_parent}
to_visit = {main_parent}
while to_visit:
parent = to_visit.pop()
for child in models.Directory.objects.filter(parent=parent):
child.classification = serializer.data['classification']
to_visit.add(child)
to_update.add(child)
models.Directory.objects.bulk_update(to_update, fields=['classification'])
data = models.Directory.objects.filter(directory__in=to_update)
response = self.get_serializer(data, many=True)
return Response(response.data)
else:
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, *args, **kwargs):
"""
This will set the classification of a directory and none of its children.
"""
return super().update(request, *args, **kwargs)
class InitialSetupSerializer(serializers.Serializer):
username = serializers.CharField()
email = serializers.EmailField(required=False, allow_blank=True)
password = serializers.CharField()
class InitialSetupRequired(serializers.Serializer):
required = serializers.BooleanField()
class InitialSetup(viewsets.GenericViewSet):
permission_classes = [permissions.AllowAny]
@swagger_auto_schema(responses={status.HTTP_200_OK: InitialSetupRequired(many=False)})
@action(detail=False, methods=['get'], serializer_class=InitialSetupRequired)
def required(self, _request: Request) -> Response:
serializer = self.get_serializer({'required': User.objects.count() == 0})
return Response(serializer.data)
@action(methods=['post'], detail=False, serializer_class=InitialSetupSerializer)
def create_user(self, request: Request) -> Response:
if User.objects.count() == 0:
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
admin = User(
username=serializer.data['username'],
email=serializer.data['email'],
is_superuser=True,
is_active=True,
is_staff=True
)
admin.set_password(serializer.data['password'])
admin.save()
return Response(serializer.data, status.HTTP_201_CREATED)
else:
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
else:
return Response({}, status.HTTP_400_BAD_REQUEST)

View File

@@ -0,0 +1,23 @@
{% load static %}
{% load render_bundle from webpack_loader %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="{% static "img/logo.svg" %}">
<title>CBWebReader</title>
</head>
<body>
<noscript>
<strong>We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app">
<app id="inspire"></app>
</div>
{% render_bundle 'main' %}
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1,4 +1,3 @@
from django.conf.urls import url
from django.urls import path from django.urls import path
from . import feeds, views from . import feeds, views
@@ -17,7 +16,7 @@ urlpatterns = [
path("recent/", views.recent_comics, name="recent_comics"), path("recent/", views.recent_comics, name="recent_comics"),
path("recent/json/", views.recent_comics_json, name="recent_comics_json"), path("recent/json/", views.recent_comics_json, name="recent_comics_json"),
path("edit/", views.comic_edit, name="comic_edit"), path("edit/", views.comic_edit, name="comic_edit"),
path("feed/<user_selector>/", feeds.RecentComics()), # path("feed/<user_selector>/", feeds.RecentComics()),
path("<directory_selector>/", views.comic_list, name="comic_list"), path("<directory_selector>/", views.comic_list, name="comic_list"),
path("<directory_selector>/thumb", views.directory_thumbnail, name="directory_thumbnail"), path("<directory_selector>/thumb", views.directory_thumbnail, name="directory_thumbnail"),
path("action/<operation>/<item_type>/<selector>/", views.perform_action, name="perform_action") path("action/<operation>/<item_type>/<selector>/", views.perform_action, name="perform_action")

View File

@@ -1,5 +1,6 @@
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass from dataclasses import dataclass
from itertools import chain
from pathlib import Path from pathlib import Path
from typing import Union, Iterable from typing import Union, Iterable
@@ -37,6 +38,7 @@ class Breadcrumb:
def __init__(self): def __init__(self):
self.name = "Home" self.name = "Home"
self.url = "/comic/" self.url = "/comic/"
self.selector = ''
def __str__(self): def __str__(self):
return self.name return self.name
@@ -60,11 +62,13 @@ def generate_breadcrumbs_from_path(directory=False, book=False):
bc = Breadcrumb() bc = Breadcrumb()
bc.name = item.name bc.name = item.name
bc.url = "/comic/" + urlsafe_base64_encode(item.selector.bytes) bc.url = "/comic/" + urlsafe_base64_encode(item.selector.bytes)
bc.selector = item.selector
output.append(bc) output.append(bc)
if book: if book:
bc = Breadcrumb() bc = Breadcrumb()
bc.name = book.file_name bc.name = book.file_name
bc.url = "/read/" + urlsafe_base64_encode(book.selector.bytes) bc.url = "/read/" + urlsafe_base64_encode(book.selector.bytes)
bc.selector = book.selector
output.append(bc) output.append(bc)
return output return output
@@ -87,9 +91,9 @@ class DirFile:
item_type: str = '' item_type: str = ''
percent: int = 0 percent: int = 0
selector: str = '' selector: str = ''
total: int = None total: int = 0
total_read: int = None total_read: int = 0
total_unread: int = None total_unread: int = 0
def __post_init__(self): def __post_init__(self):
self.item_type = type(self.obj).__name__ self.item_type = type(self.obj).__name__
@@ -111,36 +115,40 @@ class DirFile:
self.name = self.obj.name self.name = self.obj.name
elif isinstance(self.obj, ComicBook): elif isinstance(self.obj, ComicBook):
self.name = self.obj.file_name self.name = self.obj.file_name
@property
def type(self):
return 'ComicBook'
@property
def title(self):
return self.name
def generate_directory(user: User, directory=False): @property
def progress(self):
return self.total_read
@property
def thumbnail(self):
return '/error.jpg'
def generate_directory(user: User, directory=None):
""" """
:type user: User :type user: User
:type directory: Directory :type directory: Directory
""" """
base_dir = settings.COMIC_BOOK_VOLUME base_dir = settings.COMIC_BOOK_VOLUME
files = [] files = []
if directory: dir_path = Path(base_dir, directory.path) if directory else base_dir
dir_path = Path(base_dir, directory.path) dir_list = [x for x in sorted(dir_path.glob('*')) if x.is_dir()]
dir_list = [x for x in sorted(dir_path.glob('*')) if Path(base_dir, directory.path, x).is_dir()]
else:
dir_path = base_dir
dir_list = [x for x in sorted(dir_path.glob('*')) if Path(base_dir, x).is_dir()]
file_list = [x for x in sorted(dir_path.glob('*')) if x.is_file()] file_list = [x for x in sorted(dir_path.glob('*')) if x.is_file()]
if directory:
dir_list_obj = Directory.objects.filter(name__in=[x.name for x in dir_list], parent=directory) dir_list_obj = Directory.objects.filter(name__in=[x.name for x in dir_list], parent=directory)
file_list_obj = ComicBook.objects.filter(file_name__in=[x.name for x in file_list], directory=directory) file_list_obj = ComicBook.objects.filter(file_name__in=[x.name for x in file_list], directory=directory)
else:
dir_list_obj: Iterable[Directory] = Directory.objects.filter(name__in=[x.name for x in dir_list], parent__isnull=True) for file in chain(file_list_obj, dir_list_obj):
file_list_obj: Iterable[ComicBook] = ComicBook.objects.filter(file_name__in=[x.name for x in file_list], directory__isnull=True)
for file in file_list_obj:
if file.thumbnail and not Path(file.thumbnail.path).exists(): if file.thumbnail and not Path(file.thumbnail.path).exists():
file.thumbnail.delete() file.thumbnail.delete()
file.save() file.save()
for folder in dir_list_obj:
if folder.thumbnail and not Path(folder.thumbnail.path).exists():
folder.thumbnail.delete()
folder.save()
dir_list_obj = dir_list_obj.annotate( dir_list_obj = dir_list_obj.annotate(
total=Count('comicbook', distinct=True), total=Count('comicbook', distinct=True),
@@ -186,10 +194,27 @@ def generate_directory(user: User, directory=False):
files.append(DirFile(directory_obj)) files.append(DirFile(directory_obj))
files = [file for file in files if file.obj.classification <= user.usermisc.allowed_to_read] files = [file for file in files if file.obj.classification <= user.usermisc.allowed_to_read]
comics_to_annotate = []
for file_name in file_list: for file_name in file_list:
if file_name.suffix.lower() in [".rar", ".zip", ".cbr", ".cbz", ".pdf"]: if file_name.suffix.lower() in [".rar", ".zip", ".cbr", ".cbz", ".pdf"]:
book = ComicBook.process_comic_book(file_name, directory) book = ComicBook.process_comic_book(file_name, directory)
files.append(DirFile(book)) ComicStatus(user=user, comic=book).save()
comics_to_annotate.append(book.selector)
if comics_to_annotate:
new_comics = ComicBook.objects.filter(selector__in=comics_to_annotate).annotate(
total=Count('comicpage', distinct=True),
total_read=F('comicstatus__last_read_page'),
finished=F('comicstatus__finished'),
unread=F('comicstatus__unread'),
user=F('comicstatus__user'),
classification=Case(
When(directory__isnull=True, then=Directory.Classification.C_18),
default=F('directory__classification'),
output_field=PositiveSmallIntegerField(choices=Directory.Classification.choices)
)
).filter(Q(user__isnull=True) | Q(user=user.id))
files.extend([DirFile(b) for b in new_comics])
files.sort(key=lambda x: x.name) files.sort(key=lambda x: x.name)
files.sort(key=lambda x: x.item_type, reverse=True) files.sort(key=lambda x: x.item_type, reverse=True)
return files return files

View File

@@ -339,6 +339,16 @@ def get_image(request, comic_selector, page):
return FileResponse(img, content_type=content) return FileResponse(img, content_type=content)
@login_required
def get_image_api(request, selector, page):
book = ComicBook.objects.get(selector=selector)
misc, _ = UserMisc.objects.get_or_create(user=request.user)
if book.directory and book.directory.classification > misc.allowed_to_read:
return HttpResponse(status=401)
img, content = book.get_image(int(page))
return FileResponse(img, content_type=content)
@xframe_options_sameorigin @xframe_options_sameorigin
@login_required @login_required
def comic_thumbnail(request, comic_selector): def comic_thumbnail(request, comic_selector):

View File

@@ -1,7 +1,4 @@
from django import forms from django import forms
from django.conf import settings
from snowpenguin.django.recaptcha2.fields import ReCaptchaField
from snowpenguin.django.recaptcha2.widgets import ReCaptchaWidget
class LoginForm(forms.Form): class LoginForm(forms.Form):
@@ -17,8 +14,3 @@ class LoginForm(forms.Form):
label="Password", label="Password",
widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Password", "required": True}), widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Password", "required": True}),
) )
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
if settings.CBREADER_USE_RECAPTCHA if hasattr(settings, "CBREADER_USE_RECAPTCHA") else False:
self.fields["captcha"] = ReCaptchaField(widget=ReCaptchaWidget())

View File

@@ -1,9 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load recaptcha2 %}
{% load bootstrap4 %} {% load bootstrap4 %}
{% block title %}CBWebReader - Login{% endblock %} {% block title %}CBWebReader - Login{% endblock %}
{% block content %} {% block content %}
{% recaptcha_init %}
<div class="container"> <div class="container">
{% if error %} {% if error %}
<div class="alert alert-danger" role="alert"><p>Your username and password didn't match. Please try again.</p></div> <div class="alert alert-danger" role="alert"><p>Your username and password didn't match. Please try again.</p></div>

View File

@@ -14,24 +14,25 @@ services:
- 8000 - 8000
volumes: volumes:
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
# - c:/comics:/comics
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- .env:/src/.env - .env:/src/.env
command: /bin/bash entrypoint.sh command: /bin/bash /src/entrypoint.sh
cbwebreader-cron: # cbwebreader-cron:
build: . # build: .
env_file: .env # env_file: .env
links: # links:
- database # - database
depends_on: # depends_on:
database: # database:
condition: service_healthy # condition: service_healthy
volumes: # volumes:
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} # - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
- media_files:/media # - media_files:/media
- .env:/src/.env # - .env:/src/.env
command: /bin/bash entrypoint-cron.sh # command: /bin/bash entrypoint-cron.sh
database: database:
image: postgres:11.4-alpine image: postgres:11.4-alpine

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
frontend/README.md Normal file
View File

@@ -0,0 +1,24 @@
# frontend
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
frontend/babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

19
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

20566
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

69
frontend/package.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "webpack-dev-server --config webpack.dev.js",
"build": "npx webpack --config webpack.prod.js",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@coreui/coreui": "^4.2.0",
"@coreui/vue": "^4.3.0",
"@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/vue-fontawesome": "^3.0.1",
"axios": "^0.27.2",
"axios-jwt": "^1.8.0",
"bootstrap": "^4.6.2",
"core-js": "^3.8.3",
"hammerjs": "^2.0.8",
"jwt-decode": "^3.1.2",
"pdfvuer": "^2.0.1",
"reveal.js": "^4.3.1",
"reveal.js-menu": "^2.1.0",
"style-loader": "^3.3.1",
"timeago.js": "^4.0.2",
"vue": "^3.2.13",
"vue-loader": "^17.0.0",
"vue-router": "^4.0.3",
"vue-toast-notification": "3.0",
"vuejs-paginate-next": "^1.0.2",
"vuex": "^4.0.0",
"webpack": "^5.74.0",
"webpack-bundle-tracker": "^1.6.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"mini-css-extract-plugin": "^2.6.1",
"webpack-cli": "^4.10.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

138
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xml:space="preserve"
width="5.2083335in"
height="5.2083335in"
version="1.1"
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
viewBox="0 0 32.77887 32.77723"
id="svg48"
sodipodi:docname="logo.svg"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)"><metadata
id="metadata52"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1777"
inkscape:window-height="1057"
id="namedview50"
showgrid="false"
inkscape:zoom="0.63130142"
inkscape:cx="-31.625386"
inkscape:cy="264.63314"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="_2302217657600"
inkscape:document-rotation="0" />
<defs
id="defs31">
<font
id="FontID0"
horiz-adv-x="722"
font-variant="normal"
style="fill-rule:nonzero"
font-style="normal"
font-weight="700"
horiz-origin-x="0"
horiz-origin-y="0"
vert-origin-x="512"
vert-origin-y="768"
vert-adv-y="1024">
<font-face
font-family="Futura Md BT"
id="font-face10">
<font-face-src>
<font-face-name
name="Futura Md BT Bold" />
</font-face-src>
</font-face>
<missing-glyph
id="missing-glyph14"><path
d="M0 0z"
id="path12" /></missing-glyph>
<glyph
unicode="A"
horiz-adv-x="722"
d="M265.169 266.998l193.83 0 -72.8403 235.995c-2.15538,7.17818 -5.33072,18.6671 -9.16036,34.6785 -4.00285,15.9921 -9.00641,36.4875 -15.1646,61.4861 -4.17605,-17.4932 -8.33285,-34.3321 -12.5089,-50.3243 -3.9836,-15.8382 -8.15965,-31.1568 -12.3165,-45.8403l-71.8396 -235.995zm-273.175 -266.998l246.829 715.009 244.174 0 247.003 -715.009 -193.83 0 -36.1603 127.995 -276.851 0 -37.1611 -127.995 -194.003 0z"
id="glyph16" />
<glyph
unicode="B"
horiz-adv-x="678"
d="M256.991 428.998l39.0085 0c48.1689,0 81.1731,5.33072 99.1667,15.8382 17.8396,10.6614 26.8268,29.0014 26.8268,55.0007 0,27 -8.33285,45.8211 -25.1525,56.8289 -16.6657,10.8346 -49.3428,16.3385 -97.8388,16.3385l-42.0107 0 0 -144.006zm-180.994 -428.998l0 715.009 196.178 0c80.6535,0 137.001,-2.67498 169.159,-7.8325 32.0035,-5.17676 59.5039,-14.1832 82.6742,-26.846 26.3264,-14.6643 46.4946,-34.3321 60.3314,-58.8303 13.8368,-24.6714 20.6686,-52.9992 20.6686,-85.3299 0,-40.6635 -10.3343,-72.9942 -31.0028,-97.0113 -20.6686,-23.8246 -53.6728,-41.9914 -99.0128,-54.5003 50.6707,-3.82965 90.5067,-21.65 119.008,-53.3264 28.6742,-31.8303 42.9921,-74.1682 42.9921,-127.341 0,-37.9886 -7.98645,-71.4932 -24.1518,-100.495 -16.0114,-29.0014 -39.1817,-51.4982 -69.0106,-67.3364 -24.325,-12.99 -54.5003,-22.3236 -90.1603,-27.8275 -35.8332,-5.50392 -94.3364,-8.33285 -175.663,-8.33285l-202.009 0zm180.994 146.008l68.3371 0c46.1675,0 78.8446,5.83107 98.012,17.32 19.1675,11.6814 28.655,31.0028 28.655,58.0028 0,30.0021 -8.83321,50.8439 -26.3264,62.8332 -17.4932,11.8353 -49.9971,17.8396 -97.3385,17.8396l-71.3392 0 0 -155.996z"
id="glyph18" />
<glyph
unicode="D"
horiz-adv-x="766"
d="M75.9964 0l0 715.009 149.01 0c111.002,0 189.5,-5.17676 235.494,-15.6842 46.1675,-10.315 86.5039,-27.8275 121.336,-52.1525 45.3207,-31.6764 79.6721,-72.1667 102.996,-121.336 23.4975,-49.3428 35.1596,-105.671 35.1596,-168.832 0,-63.1796 -11.6621,-119.335 -35.1596,-168.678 -23.3243,-49.3236 -57.6757,-89.8332 -102.996,-121.49 -34.5053,-23.9978 -73.841,-41.1639 -118.161,-51.4982 -44.1853,-10.1803 -112.85,-15.3378 -205.839,-15.3378l-32.8311 0 -149.01 0zm193.003 159.998l32.6771 0c76.4967,0 132.498,15.665 167.658,47.1682 35.1596,31.33 52.6721,81.1731 52.6721,149.664 0,68.3371 -17.5125,118.334 -52.6721,150.338 -35.1596,31.8303 -91.161,47.8225 -167.658,47.8225l-32.6771 0 0 -394.993z"
id="glyph20" />
<glyph
unicode="E"
horiz-adv-x="566"
d="M75.9964 0l0 715.009 438.004 0 0 -157.016 -248.003 0 0 -123.992 233.839 0 0 -152.993 -233.839 0 0 -121.009 248.003 0 0 -159.998 -438.004 0z"
id="glyph22" />
<glyph
unicode="R"
horiz-adv-x="641"
d="M75.9964 0l0 715.009 204.011 0c79.6528,0 135.327,-3.67569 166.657,-11.0078 31.33,-7.33214 58.5032,-19.6678 81.3271,-36.8339 25.6721,-19.4946 45.5132,-44.4932 59.3499,-74.6685 13.8175,-30.3293 20.6686,-63.6607 20.6686,-100.167 0,-55.3278 -13.6828,-100.341 -40.8367,-135 -27.3464,-34.6593 -67.0092,-57.6564 -119.181,-68.9914l195.004 -288.34 -220.003 0 -164.001 280.007 0 -280.007 -182.996 0zm182.996 376.999l36.0064 0c42.0107,0 72.6671,7.15894 92.0078,21.4961 19.3214,14.3371 29.0014,36.8339 29.0014,67.3364 0,35.8332 -9.00641,61.3321 -27,76.4967 -18.1668,15.1646 -48.3421,22.67 -91.0071,22.67l-39.0085 0 0 -187.999z"
id="glyph24" />
<glyph
unicode="W"
horiz-adv-x="970"
d="M562.997 715.009l89.8332 -340.011c4.17605,-15.1646 8.1789,-31.1568 11.6621,-47.8225 3.67569,-16.5117 7.33214,-35.6792 11.335,-57.1753 4.83036,25.4989 9.00641,46.3407 12.3357,62.8332 3.50249,16.4925 6.83178,30.5025 10.0071,42.1646l84.0021 340.011 196.832 0 -202.163 -715.009 -180.667 0 -88.3321 305.333c-3.34854,10.6614 -8.67925,31.6764 -16.1846,63.0064 -3.15609,13.9907 -5.83107,24.8254 -7.8325,32.6579 -1.50107,-6.83178 -3.82965,-16.4925 -6.83178,-28.8282 -7.4861,-31.6764 -13.1632,-53.8268 -16.9929,-66.836l-87.0042 -305.333 -181.167 0 -201.836 715.009 197.006 0 82.0007 -341.839c4.00285,-17.6664 7.8325,-35.1789 11.5082,-52.8453 3.82965,-17.4932 7.33214,-35.66 10.4882,-54.1539 3.00214,13.6636 6.17747,28.0007 9.50676,42.9921 3.50249,15.0107 8.66001,36.3335 15.4918,64.0071l89.8332 341.839 157.17 0z"
id="glyph26" />
</font>
<style
type="text/css"
id="style29">
<![CDATA[
@font-face { font-family:"Futura Md BT";font-variant:normal;font-style:normal;font-weight:bold;src:url("#FontID0") format(svg)}
.fil2 {fill:#336666}
.fil0 {fill:#E6E6E6}
.fil1 {fill:#003333;fill-rule:nonzero}
.fnt0 {font-weight:bold;font-size:1.287px;font-family:'Futura Md BT'}
]]>
</style>
</defs>
<g
id="Layer_x0020_1"
transform="translate(11.673934,11.673698)">
<metadata
id="CorelCorpID_0Corel-Layer" />
<g
id="_2302217657600"
transform="matrix(3.4756515,0,0,3.4756515,-11.673934,-11.673934)">
<g
id="g42">
<path
class="fil1"
d="M 6.32,3.205 C 6.038,2.964 5.738,2.783 5.424,2.663 5.108,2.543 4.777,2.482 4.43,2.482 3.759,2.482 3.212,2.697 2.791,3.129 2.369,3.561 2.159,4.119 2.159,4.805 c 0,0.662 0.205,1.21 0.615,1.642 0.41,0.432 0.928,0.647 1.552,0.647 0.364,0 0.71,-0.065 1.04,-0.194 C 5.694,6.771 6.01,6.577 6.312,6.316 v 1.13 C 6.044,7.64 5.751,7.785 5.434,7.879 5.117,7.975 4.774,8.022 4.406,8.022 3.936,8.022 3.501,7.945 3.102,7.791 2.702,7.636 2.352,7.41 2.049,7.11 1.749,6.816 1.52,6.469 1.358,6.07 1.197,5.671 1.117,5.245 1.117,4.795 1.117,4.343 1.197,3.92 1.358,3.525 1.52,3.128 1.752,2.781 2.058,2.481 2.363,2.178 2.713,1.949 3.108,1.794 3.502,1.638 3.93,1.56 4.389,1.56 4.75,1.56 5.095,1.613 5.422,1.718 5.75,1.824 6.067,1.983 6.376,2.197 L 6.321,3.204 Z"
id="path36" />
<path
class="fil2"
d="M 5.438,4.315 H 5.819 C 6.295,4.315 6.631,4.25 6.829,4.12 7.025,3.99 7.124,3.77 7.124,3.463 7.124,3.126 7.034,2.891 6.852,2.755 6.771,2.694 6.658,2.647 6.512,2.614 L 6.539,2.118 6.466,2.067 C 6.272,1.932 6.068,1.814 5.854,1.717 h 0.155 c 0.443,0 0.77,0.024 0.981,0.072 C 7.202,1.836 7.381,1.915 7.531,2.024 7.722,2.165 7.87,2.348 7.974,2.575 8.079,2.803 8.132,3.053 8.132,3.328 8.132,3.656 8.056,3.93 7.905,4.15 7.753,4.371 7.534,4.524 7.245,4.61 7.603,4.666 7.885,4.829 8.09,5.1 8.295,5.37 8.398,5.711 8.398,6.123 8.398,6.371 8.355,6.608 8.268,6.833 8.182,7.057 8.059,7.247 7.899,7.404 7.73,7.575 7.522,7.695 7.272,7.765 7.022,7.835 6.605,7.87 6.017,7.87 H 5.909 C 6.082,7.789 6.248,7.691 6.404,7.578 L 6.47,7.53 V 7.006 C 6.505,7.002 6.537,6.998 6.567,6.993 6.74,6.964 6.877,6.916 6.979,6.845 7.102,6.765 7.197,6.654 7.264,6.518 7.331,6.38 7.364,6.23 7.364,6.063 7.364,5.868 7.324,5.696 7.242,5.551 7.16,5.405 7.044,5.291 6.893,5.211 6.798,5.162 6.689,5.128 6.567,5.105 6.445,5.083 6.283,5.072 6.081,5.072 H 5.78 5.436 V 6.696 C 5.393,6.716 5.349,6.734 5.304,6.752 5.03,6.859 4.747,6.918 4.455,6.932 V 2.643 c 0.312,0.003 0.617,0.058 0.909,0.17 0.024,0.009 0.048,0.019 0.072,0.029 v 1.474 z"
id="path38" />
<path
class="fil1"
d="M 6.32,3.205 C 6.038,2.964 5.738,2.783 5.424,2.663 5.108,2.543 4.777,2.482 4.43,2.482 3.759,2.482 3.212,2.697 2.791,3.129 2.369,3.561 2.159,4.119 2.159,4.805 c 0,0.662 0.205,1.21 0.615,1.642 0.41,0.432 0.928,0.647 1.552,0.647 0.364,0 0.71,-0.065 1.04,-0.194 C 5.694,6.771 6.01,6.577 6.312,6.316 v 1.13 C 6.044,7.64 5.751,7.785 5.434,7.879 5.117,7.975 4.774,8.022 4.406,8.022 3.936,8.022 3.501,7.945 3.102,7.791 2.702,7.636 2.352,7.41 2.049,7.11 1.749,6.816 1.52,6.469 1.358,6.07 1.197,5.671 1.117,5.245 1.117,4.795 1.117,4.343 1.197,3.92 1.358,3.525 1.52,3.128 1.752,2.781 2.058,2.481 2.363,2.178 2.713,1.949 3.108,1.794 3.502,1.638 3.93,1.56 4.389,1.56 4.75,1.56 5.095,1.613 5.422,1.718 5.75,1.824 6.067,1.983 6.376,2.197 L 6.321,3.204 Z"
id="path40" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

13
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<the-navbar />
<router-view/>
</template>
<style>
</style>
<script>
import TheNavbar from "@/components/TheNavbar.vue";
export default {
components: {TheNavbar}
}
</script>

40
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,40 @@
import axios from "axios";
import router from "@/router";
import store from "@/store";
import jwtDecode from "jwt-decode";
async function get_access_token() {
let access = jwtDecode(store.state.jwt.access)
let refresh = jwtDecode(store.state.jwt.refresh)
if (access.exp - Date.now()/1000 < 5) {
if (refresh.exp - Date.now()/1000 < 5) {
await router.push({name: 'login', params: { username: 'eduardo' }})
return null
} else {
return store.dispatch('refreshToken').then(() => {return store.state.jwt.access})
}
}
return store.state.jwt.access
}
const axios_jwt = axios.create();
axios_jwt.interceptors.request.use(async function (config) {
let access_token = await get_access_token().catch(() => {
if (router.currentRoute.value.fullPath.includes('login')){
router.push({name: 'login'})
}else {
router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }})
}
})
config.headers = {
Authorization: "Bearer " + access_token
}
return config
}, function (error) {
// Do something with request error
return Promise.reject(error);
});
export default axios_jwt

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,79 @@
<template>
<CButton color="secondary" @click="visible = true">Add User</CButton>
<CModal :visible="visible" @close="visible = false">
<CModalHeader>
<CModalTitle>Add user</CModalTitle>
</CModalHeader>
<CForm @submit="addUser">
<CModalBody>
<CFormInput
type="text"
label="Username"
v-model="username"
/>
<CFormInput
type="email"
label="Email address"
text="Must be 8-20 characters long."
v-model="email"
feedback-invalid="Email address invalid."
/>
</CModalBody>
<CModalFooter>
<CButton color="secondary" @click="visible = false">
Close
</CButton>
<CButton color="primary" type="submit">Submit</CButton>
</CModalFooter>
</CForm>
</CModal>
</template>
<script>
import api from "@/api";
export default {
name: "AddUser",
data() {
return {
visible: false,
username: '',
email: ''
}
},
props: {
messages: Array,
},
methods: {
addUser() {
let payload = {
username: this.username,
email: this.email
}
api.post('/api/users/', payload).then(response => {
payload = {
username: response.data.username
}
api.patch('/api/users/' + response.data.id + '/reset_password/', payload).then(response2 => {
this.$emit('add-message', {
color: 'success',
text: 'New user "' + response.data.username + '" created with password "' + response2.data.password + '".'
})
this.visible=false
this.$emit('user-added')
})
}).catch(err => {
this.$emit('add-message', {
color: 'danger',
text: 'Cannot create user "' + this.username + '" with error "' + (err.response.data.username? err.response.data.username: err.response.data.email) + '".'
})
this.visible = false
})
}
},
emits: ['user-added', 'add-message']
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,23 @@
<template>
<CAlert :color="message.color" dismissible v-for="message in messages" :key="message.text">
{{message.text}}
</CAlert>
</template>
<script>
export default {
name: "AlertMessages",
data() {
return {
}
},
props: {
messages: Array
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,178 @@
<template>
<CCard class="col-xl-2 col-lg-2 col-md-3 col-sm-4 p-0 m-1 ">
<CCardImage orientation="top" :src="thumbnail"/>
<CCardBody class="pb-0 pt-0 pl-1 pr-1 card-img-overlay d-flex">
<span class="badge rounded-pill bg-primary unread-badge" v-if="this.unread > 0 && data.type === 'Directory'">{{ this.unread }}</span>
<span class="badge rounded-pill bg-warning classification-badge" v-if="card_type === 'Directory'" >{{ this.$store.state.classifications.find(i => i.value === classification).label }}</span>
<CCardTitle class="align-self-end pb-5 mb-4 text-break" style="">
<router-link :to="(data.type === 'Directory' ? {'name': 'browse', params: { selector: data.selector }} : {'name': 'read', params: { selector: data.selector }})">{{ data.title }}</router-link>
</CCardTitle>
</CCardBody>
<CCardFooter class="pl-0 pr-0 pt-0">
<CProgress class="mb-1 position-relative" >
<CProgressBar :value="progressPercentCalc" />
<small class="justify-content-center d-flex position-absolute w-100 h-100" style="line-height: normal">{{ progressCalc }} / {{data.total}}</small>
</CProgress>
<CButtonGroup class="w-100">
<CButton color="primary" @click="updateComic('mark_unread')" ><font-awesome-icon icon='book' /></CButton>
<CButton color="primary" @click="updateComic('mark_read')" ><font-awesome-icon icon='book-open' /></CButton>
<CDropdown variant="btn-group">
<CDropdownToggle color="primary"><font-awesome-icon icon='edit' /></CDropdownToggle>
<CDropdownMenu>
<CDropdownItem @click="updateComic('mark_unread')"><font-awesome-icon icon='book' />Mark Un-read</CDropdownItem>
<CDropdownItem @click="updateComic('mark_read')"><font-awesome-icon icon='book-open' />Mark read</CDropdownItem>
<CDropdownItem v-if="data.type === 'ComicBook'" @click="$emit('markPreviousRead', data.selector)"><font-awesome-icon icon='book' /><font-awesome-icon icon='turn-up' />Mark previous comics read</CDropdownItem>
<CDropdownItem v-if="data.type === 'Directory'" @click="editDirectoryVisible = true"><font-awesome-icon icon='edit' />Edit comic</CDropdownItem>
</CDropdownMenu>
</CDropdown>
</CButtonGroup>
</CCardFooter>
</CCard>
<CModal :visible="editDirectoryVisible" @close="editDirectoryVisible = false">
<CModalHeader>
<CModalTitle>{{ data.title }}</CModalTitle>
</CModalHeader>
<CForm @submit="updateDirectory">
<CModalBody>
<CFormSelect
label="Classification"
aria-label="Set Classification"
v-model="new_classification"
:options="[...this.$store.state.classifications]">
</CFormSelect>
<CFormCheck
label="Recursive"
class="mt-2"
v-model="recursive"
/>
</CModalBody>
<CModalFooter>
<CButton color="secondary" @click="editDirectoryVisible = false ">
Close
</CButton>
<CButton color="primary" type="submit">Save changes</CButton>
</CModalFooter>
</CForm>
</CModal>
</template>
<script>
import {useToast} from "vue-toast-notification";
import api from "@/api";
export default {
name: "ComicCard",
components: {
},
props: {
data: Object
},
data () {
return {
thumbnail: '/static/img/placeholder.png',
unread: 0,
progress: 0,
classification: '0',
new_classification: '0',
card_type: '',
editDirectoryVisible: false,
recursive: true
}},
methods: {
updateThumbnail () {
api.get('/api/generate_thumbnail/' + this.data.selector + '/')
.then((response) => {
if (response.data.thumbnail) {
this.$emit('updateThumbnail', response.data)
this.thumbnail = response.data.thumbnail
}
}).catch(() => {
useToast().error('Error Generating Thumbnail: ' + this.data.title, {position:'top'});
})
},
updateComic(action){
let payload = { selectors: [this.data.selector] }
api.put('/api/action/' + action +'/', payload).then(() => {
this.$emit('updateComicList')
}).catch(() => {
useToast().error('action: ' + action + ' Failed', {position:'top'});
})
},
updateDirectory() {
let payload = {
selector: this.data.selector,
classification: ~~this.new_classification
}
if (this.recursive){
api.put('/api/directory/' + this.data.selector + '/', payload).then(response => {
this.classification = response.data[0].classification.toString()
useToast().success('Change classification of ' + this.data.title + ' to "' + this.$store.state.classifications.find(i => i.value === this.classification).label + '"', {position:'top'});
this.editDirectoryVisible = false
})
} else {
api.patch('/api/directory/' + this.data.selector + '/', payload).then(response => {
this.classification = response.data.classification.toString()
useToast().success('Change classification of ' + this.data.title + ' to "' + this.$store.state.classifications.find(i => i.value === this.classification).label + '"', {position:'top'});
this.editDirectoryVisible = false
})
}
}
},
mounted () {
if (this.data.thumbnail) {
this.thumbnail = this.data.thumbnail
} else {
this.updateThumbnail()
}
this.unread = this.data.total - this.data.progress
this.classification = this.data.classification.toString()
this.new_classification = this.classification
this.card_type = this.data.type
},
beforeUpdate() {
this.unread = this.data.total - this.data.progress
},
emits: ['updateComicList', 'markPreviousRead', 'updateThumbnail'],
computed: {
progressCalc () {
if (this.data.type === 'ComicBook'){
return (this.data.unread ? 0 : this.data.progress)
} else {
return this.data.progress
}
},
progressPercentCalc () {
if (this.data.type === 'ComicBook') {
return (this.data.unread ? 0 : this.data.progress / this.data.total * 100)
} else {
return this.data.progress / this.data.total * 100
}
}
}
}
</script>
<style scoped>
.card-title a {
color: white;
text-shadow: .2rem .2rem .3rem black ;
}
.card .unread-badge {
position: absolute;
top: 10px;
left: 10px;
padding: 5px;
color: #fff;
}
.dropdown-item {
cursor: pointer;
}
.card .classification-badge {
position:absolute;
top:10px;
right: 10px;
padding:5px;
color:black;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<CButtonGroup>
<CButton :color="color" v-if="!confirm" @click="confirm = !confirm">{{ label }}</CButton>
<CButton color="success" class="text-nowrap" v-if="confirm" variant="outline" @click="performAction">
<font-awesome-icon icon='check' class=""/>
Yes
</CButton>
<CButton color="danger" class="text-nowrap" v-if="confirm" variant="outline"
@click="confirm = !confirm">
<font-awesome-icon icon='times' class=""/>
No
</CButton>
</CButtonGroup>
</template>
<script>
export default {
name: 'confirm-button',
data () {
return {
confirm: false,
}
},
props: {
label: String,
color: {
type: String,
default: 'danger'
},
action: {}
},
methods: {
performAction() {
this.action()
this.confirm = false
}
}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<h1>Create your admin account.</h1>
<CForm @submit="saveForm">
<CFormInput
type="text"
label="Username"
v-model="username"
/>
<CFormInput
type="email"
label="Email address"
text="Must be 8-20 characters long."
v-model="email"
feedback-invalid="Email address invalid."
/>
<CFormInput
type="password"
label="Password"
v-model="password"
/>
<CFormInput
type="password"
label="Confirm Password"
v-model="confirm_password"
/>
<CButton color="primary" type="submit" class="mr-5 mt-2">Save</CButton>
</CForm>
</template>
<script>
import axios from "axios";
import router from "@/router";
export default {
name: "InitialSetup",
data () {
return {
username: '',
email: '',
password: '',
confirm_password: ''
}
},
methods: {
saveForm() {
if (this.password === this.confirm_password) {
let payload = {
username: this.username,
email: this.email,
password: this.password
}
axios.post('/api/initial_setup/create_user/', payload).then(() => {
router.push({'name': 'home'})
})
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,134 @@
<template>
<CContainer>
<CForm @submit="updateAccount">
<CFormInput
type="text"
label="Username"
readonly
v-model="username"
/>
<CFormInput
type="email"
label="Email address"
:placeholder="email"
text="Must be 8-20 characters long."
v-model="email"
feedback-invalid="Email address invalid."
:valid="validateEmail(email)"
/>
<CFormInput
type="password"
label="Current Password"
placeholder="Enter Current Password"
text="Must enter current password to change settings."
v-model="current_password"
feedback-invalid="Wrong Password."
:valid="current_password.length > 0"
/>
<CFormInput
type="password"
label="New Password"
placeholder="Enter New Password"
text="Must be at least 9 characters long."
v-model="new_password"
feedback-invalid="Password is not complex enough."
:valid="checkNewPassword(new_password)"
/>
<CFormInput
type="password"
label="New Password Confirm"
placeholder="Enter New Password"
text="Must be at least 9 characters long."
v-model="new_password_confirm"
feedback-invalid="New passwords should match."
:valid="new_password === new_password_confirm && new_password.length > 8"
/>
<CButton color="primary" type="submit">Save</CButton>
</CForm>
</CContainer>
</template>
<script>
import {CForm, CFormInput, CContainer, CButton} from "@coreui/vue";
import api from "@/api";
import {useToast} from "vue-toast-notification";
const toast = useToast();
export default {
name: "TheAccountForm",
components: {
CForm,
CFormInput,
CContainer,
CButton
},
data () {
return {
username: '',
email: '',
current_password: '',
new_password: '',
new_password_confirm: '',
}
},
mounted() {
this.updateFromServer()
},
methods: {
updateFromServer() {
api.get('/api/account/').then(response => {
this.$store.commit('updateUser', response.data)
this.username = this.$store.state.user.username
this.email = this.$store.state.user.email
this.current_password = ''
this.new_password = ''
this.new_password_confirm = ''
})
},
updateAccount () {
if (!this.current_password) {
toast.error('Please enter your current password.', {position:'top'});
} else {
if (this.email !== this.$store.state.user.email) {
let payload = {
username: this.username,
email: this.email,
password: this.current_password
}
api.patch('/api/account/update_email/', payload).then(() => {
toast.success('Email Address updated')
this.updateFromServer()
}).catch(error => {
toast.error(error.response.data.errors)
})
}
if (this.new_password === this.new_password_confirm) {
let payload = {
username: this.username,
old_password: this.current_password,
new_password: this.new_password,
new_password_confirm: this.new_password_confirm
}
api.patch('/api/account/reset_password/', payload).then(() => {
toast.success('Password reset successfully')
this.updateFromServer()
}).catch(error => {
console.log(error.response.data)
toast.error(error.response.data.errors)
})
}
}
},
validateEmail(mail){
return (/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(mail))
},
checkNewPassword(pass){
return (pass.length >= 9)
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,72 @@
<template>
<CBreadcrumb>
<template v-for="(item, index) in crumbs" :key="item.id">
<template v-if="index !== crumbs.length - 1">
<CBreadcrumbItem v-if="item.selector">
<router-link :to="{'name': 'browse', params: { selector: item.selector }}">{{ item.name }}</router-link>
</CBreadcrumbItem>
<CBreadcrumbItem v-else-if="item.route">
<router-link :to="item.route">{{ item.name }}</router-link>
</CBreadcrumbItem>
<CBreadcrumbItem v-else>
<router-link :to="{'name': 'browse'}">{{ item.name }}</router-link>
</CBreadcrumbItem>
</template>
<CBreadcrumbItem v-else active>{{ item.name }}</CBreadcrumbItem>
</template>
</CBreadcrumb>
</template>
<script>
import { CBreadcrumbItem, CBreadcrumb } from '@coreui/vue'
import api from "@/api";
export default {
name: "TheBreadcrumbs",
components: {
CBreadcrumb,
CBreadcrumbItem,
},
data () {
return {
crumbs: []
}},
props: {
selector: String,
manual_crumbs: Object
},
methods: {
updateBreadcrumbs () {
if (this.selector) {
let breadcrumb_url = '/api/browse/' + this.selector + '/breadcrumbs/'
api.get(breadcrumb_url)
.then(response => {
this.crumbs = response.data
})
.catch((error) => {
console.log(error)
})
}else if (this.manual_crumbs){
this.crumbs = this.manual_crumbs
} else {
this.crumbs = [{id: 0, selector: '', name: 'Home'}]
}
},
},
watch: {
selector() {
this.updateBreadcrumbs()
},
manual_crumbs () {
this.updateBreadcrumbs()
}
},
mounted () {
this.updateBreadcrumbs()
},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,148 @@
<template>
<CContainer>
<CRow>
<CInputGroup>
<CFormInput placeholder="Search" aria-label="Filter comics by name" v-model="this.filters.search_string"/>
<CButton type="button" :color="(!filters.filter_read && !filters.filter_unread? 'primary' : 'secondary')" variant="outline" @click="filters.filter_read=false; filters.filter_unread=false">All</CButton>
<CButton type="button" :color="(filters.filter_read && !filters.filter_unread? 'primary' : 'secondary')" variant="outline" @click="filters.filter_read=true; filters.filter_unread=false">Read</CButton>
<CButton type="button" :color="(!filters.filter_read && filters.filter_unread? 'primary' : 'secondary')" variant="outline" @click="filters.filter_read=false; filters.filter_unread=true">Un-read</CButton>
<CDropdown variant="input-group">
<CDropdownToggle color="secondary" variant="outline">Action</CDropdownToggle>
<CDropdownMenu>
<CDropdownItem @click="markAll('mark_unread')"><font-awesome-icon icon='book' />Mark Un-read</CDropdownItem>
<CDropdownItem @click="markAll('mark_read')"><font-awesome-icon icon='book-open' />Mark read</CDropdownItem>
</CDropdownMenu>
</CDropdown>
</CInputGroup>
</CRow>
<CRow>
<template v-if="loading">
<CCol>
<CProgress class="mt-3" >
<CProgressBar color="success" variant="striped" animated :value="100"/>
</CProgress>
</CCol>
</template>
<template v-else>
<template v-for="comic in filteredComics" :key="comic.selector" >
<comic-card :data="comic" @updateComicList="updateComicList" @markPreviousRead="markPreviousRead" @updateThumbnail="updateThumbnail" />
</template>
</template>
</CRow>
</CContainer>
</template>
<script>
import ComicCard from "@/components/ComicCard";
import api from '@/api'
import store from "@/store";
export default {
name: "TheComicList",
components: {ComicCard},
data () {
return {
comics: [],
breadcrumbs: [
{id: 0, selector: '', name: 'Home'}
],
filters: {
search_string: '',
filter_read: false,
filter_unread: false
},
loading: true
}},
props: {
selector: String
},
methods: {
updateComicList () {
this.loading = true
let comic_list_url = '/api/browse/'
if (this.selector) {
comic_list_url += this.selector + '/'
}
api.get(comic_list_url)
.then(response => {
this.comics = response.data
this.loading = false
})
.catch((error) => {console.log(error)})
},
markPreviousRead (selector) {
let selectors = []
this.comics.every((item) => {
if (item.selector === selector) {
selectors.push(item.selector)
return false
} else {
if (item.type === 'ComicBook') {
selectors.push(item.selector)
}
return true
}
})
let payload = { selectors: selectors }
api.put('/api/action/mark_read/', payload).then(() => {
this.updateComicList()
})
},
markAll (action) {
let selectors = []
this.comics.filter(item => item.type === 'ComicBook').forEach((item) => {selectors.push(item.selector)})
let payload = { selectors: selectors }
api.put('/api/action/' + action + '/', payload).then(() => {
this.updateComicList()
})
},
updateThumbnail(resp){
this.comics.find(i => i.selector === resp.selector).thumbnail = resp.thumbnail
}
},
computed: {
filteredComics() {
let filtered_comics = [...this.comics]
if (this.filters.search_string) {
filtered_comics = filtered_comics.filter(comic => {
return comic.title.toLowerCase().includes(this.filters.search_string.toLowerCase()) })
}
if (this.filters.filter_read) {
filtered_comics = filtered_comics.filter(comic => comic.finished )
}
if (this.filters.filter_unread) {
filtered_comics = filtered_comics.filter(comic => comic.unread )
}
return filtered_comics
}
},
mounted () {
this.updateComicList()
},
beforeUpdate() {
let filter_id = ( this.selector ? this.selector : 'home')
if (filter_id in store.state.filters) {
this.filters = store.state.filters[filter_id]
} else {
this.filters = {
search_string: '',
filter_read: false,
filter_unread: false
}
store.state.filters[filter_id] = this.filters
}
},
watch: {
filters() {
let filter_id = ( this.selector ? this.selector : 'home')
store.state.filters[filter_id] = this.filters
}
},
}
</script>
<style scoped>
.dropdown-item {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<div class="reveal" id="comic_box" ref="comic_box" >
<div id="slides_div" class="slides" ref="slides">
<section class="" v-for="page in pages" :key="page.index" :data-menu-title="page.page_file_name" hidden>
<img :data-src="'/api/read/' + selector + '/image/' + page.index + '/'" class="w-100" :alt="page.page_file_name">
</section>
</div>
</div>
<CRow class="navButtons pb-2">
<CListGroup :layout="'horizontal'">
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="prevComic">Prev&nbsp;Comic</CListGroupItem>
<paginate
v-model="paginate_page"
:page-count="pages.length"
:click-handler="this.setPage"
:prev-text="'Prev'"
:next-text="'Next'"
:container-class="'pagination'"
>
</paginate>
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="nextComic">Next&nbsp;Comic</CListGroupItem>
</CListGroup>
</CRow>
</template>
<script>
import Reveal from "reveal.js";
import api from "@/api";
import 'reveal.js-menu/menu.css'
import Paginate from "vuejs-paginate-next";
import * as Hammer from 'hammerjs'
export default {
name: "TheComicReader",
components: {Paginate},
data () {
return {
current_page: 0,
paginate_page: 1,
deck: null,
title: '',
prev_comic: {},
next_comic: {},
pages: [],
}
},
props: {
selector: String
},
methods: {
prevPage(){
if (this.deck.isFirstSlide()){
this.prevComic()
} else {
this.current_page -= 1
this.deck.slide(this.current_page)
}
},
prevComic(){
this.$router.push({
name: this.prev_comic.route,
params: {selector: this.prev_comic.selector}
})
},
nextComic(){
this.$router.push({
name: this.next_comic.route,
params: {selector: this.next_comic.selector}
})
},
nextPage(){
if (this.deck.isLastSlide()){
this.nextComic()
} else {
this.current_page += 1
this.deck.slide(this.current_page)
}
},
setPage(pageNum){
this.current_page = pageNum-1
this.deck.slide(this.current_page)
},
keyPressDebounce(e){
clearTimeout(this.key_timeout)
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
},
keyPress(e) {
if (e.key === 'ArrowRight') {
this.nextPage()
} else if (e.key === 'ArrowLeft') {
this.prevPage()
} else if (e.key === 'ArrowUp') {
window.scrollTo({
top: window.scrollY-window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
} else if (e.key === 'ArrowDown') {
window.scrollTo({
top: window.scrollY+window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
}
}
},
watch: {
'current_page' (new_page) {
this.paginate_page = new_page + 1
}
},
mounted () {
const set_read_url = '/api/read/' + this.selector + '/set_page/'
let comic_data_url = '/api/read/' + this.selector + '/'
window.addEventListener('keyup', this.keyPressDebounce)
api.get(comic_data_url)
.then(response => {
this.title = response.data.title
this.current_page = response.data.last_read_page
this.prev_comic = response.data.prev_comic
this.next_comic = response.data.next_comic
this.pages = response.data.pages
this.deck = Reveal(this.$refs.comic_box)
this.deck.initialize({
controls: false,
width: "100%",
height: "100%",
margin: 0,
minScale: 1,
maxScale: 1,
keyboard: null,
touch: false,
transition: 'slide',
embedded: true,
plugins: [ ]
}).then(() => {
this.deck.slide(this.current_page)
this.deck.on( 'slidechanged', () => {
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
api.put(set_read_url, {page: event.indexh})
});
})
this.hammertime = new Hammer(this.$refs.comic_box, {})
this.hammertime.on('swipeleft', (_e, self=this) => {
self.nextPage()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
})
.catch((error) => {console.log(error)})
},
beforeUnmount() {
window.removeEventListener('keyup', this.keyPressDebounce)
try {
this.hammertime.off('swipeleft')
this.hammertime.off('swiperight')
this.hammertime.off('tap')
} catch (e) {
console.log(e)
}
}
}
</script>
<style scoped>
.navButtons {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: 0;
z-index: 1030;
width: auto;
cursor: pointer;
}
section {
padding-bottom: 60px;
}
.list-group-item {
/*padding: 0;*/
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<CNavbar expand="lg" color-scheme="light" class="bg-light">
<CContainer fluid>
<CNavbarBrand href="#"><img src="/static/img/logo.svg" width="35" class="d-inline-block align-top" alt="CB"> Web Reader</CNavbarBrand>
<CNavbarToggler @click="visible = !visible"/>
<CCollapse class="navbar-collapse" :visible="visible">
<CNavbarNav>
<CNavItem>
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
</CNavItem>
<CNavItem>
<router-link :to="{name: 'recent'}" class="nav-link" >Recent</router-link>
</CNavItem>
<CNavItem>
<router-link :to="{name: 'account'}" class="nav-link" >Account</router-link>
</CNavItem>
<CNavItem>
<router-link :to="{name: 'user'}" class="nav-link" v-if="this.$store.getters.is_superuser">Users</router-link>
</CNavItem>
<CNavItem>
<CNavLink @click="logout">Log Out</CNavLink>
</CNavItem>
</CNavbarNav>
</CCollapse>
</CContainer>
</CNavbar>
</template>
<script>
import { CNavbar, CNavbarNav, CContainer, CNavbarBrand, CNavbarToggler, CCollapse, CNavItem, CNavLink } from '@coreui/vue'
import store from "@/store";
import router from "@/router";
export default {
name: "TheNavbar",
components: {
CNavbar,
CNavbarNav,
CContainer,
CNavbarBrand,
CNavbarToggler,
CCollapse,
CNavItem,
CNavLink
},
data() {
return {
visible: true
}
},
methods: {
logout () {
store.commit('logOut')
router.push({name: 'login'})
}
}
}
</script>
<style scoped>
.nav-link {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<CContainer ref="pdfContainer">
<CRow class="w-100 pb-5 mb-5" v-if="loaded" >
<pdf :src="pdfdata" :page="page" ref="pdfWindow" :resize="true">
<template v-slot:loading>
loading content here...
</template>
</pdf>
</CRow>
</CContainer>
<CRow class="navButtons pb-2">
<CListGroup :layout="'horizontal'">
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="prevComic">Prev&nbsp;Comic</CListGroupItem>
<paginate
v-model="page"
:page-count="numPages"
:click-handler="this.setPage"
:prev-text="'Prev'"
:next-text="'Next'"
:container-class="'pagination'"
>
</paginate>
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="nextComic">Next&nbsp;Comic</CListGroupItem>
</CListGroup>
</CRow>
</template>
<script>
import pdfvuer from 'pdfvuer'
import api from "@/api";
import Paginate from "vuejs-paginate-next";
import * as Hammer from 'hammerjs'
export default {
name: "ThePdfReader",
components: {
pdf: pdfvuer, Paginate
},
data () {
return {
page: 1,
numPages: 0,
pdfdata: undefined,
errors: [],
scale: 'page-width',
loaded: false,
key_timeout: null,
hammertime: null,
next_comic: {},
prev_comic: {}
}
},
props: {
selector: String
},
computed: {
},
mounted () {
this.getPdf()
window.addEventListener('keyup', this.keyPressDebounce)
},
beforeUnmount() {
window.removeEventListener('keyup', this.keyPressDebounce)
},
watch: {
},
methods: {
getPdf () {
let comic_data_url = '/api/read/' + this.selector + '/'
api.get(comic_data_url)
.then(response => {
let parameter = {
url: '/api/read/' + this.selector + '/pdf/',
httpHeaders: { Authorization: 'Bearer ' + this.$store.state.jwt.access },
withCredentials: true,
}
this.pdfdata = pdfvuer.createLoadingTask(parameter);
this.pdfdata.then(pdf => {
this.numPages = pdf.numPages;
this.loaded = true
this.page = response.data.last_read_page+1
this.setReadPage(this.page)
this.next_comic = response.data.next_comic
this.prev_comic = response.data.prev_comic
this.hammertime = new Hammer(this.$refs.pdfContainer.$el, {})
this.hammertime.on('swipeleft', (_e, self=this) => {
self.nextPage()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
}).catch(e => {console.log(e)});
})
},
prevComic(){
this.$router.push({
name: this.prev_comic.route,
params: {selector: this.prev_comic.selector}
})
},
nextComic(){
this.$router.push({
name: this.next_comic.route,
params: {selector: this.next_comic.selector}
})
},
nextPage () {
if (this.page < this.numPages){
this.page += 1
this.setReadPage(this.page)
} else {
this.nextComic()
}
},
prevPage() {
if (this.page > 1){
this.page -= 1
this.setReadPage(this.page)
} else {
this.prevComic()
}
},
setPage(num) {
this.page = num
this.setReadPage(this.page)
},
setReadPage(num){
this.$refs.pdfContainer.$el.scrollIntoView()
let payload = {
page: num-1
}
api.put('/api/read/'+ this.selector +'/set_page/', payload)
},
keyPressDebounce(e){
clearTimeout(this.key_timeout)
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
},
keyPress(e) {
if (e.key === 'ArrowRight') {
this.nextPage()
} else if (e.key === 'ArrowLeft') {
this.prevPage()
} else if (e.key === 'ArrowUp') {
window.scrollTo({
top: window.scrollY-window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
} else if (e.key === 'ArrowDown') {
window.scrollTo({
top: window.scrollY+window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
}
}
}
}
</script>
<style scoped>
.navButtons {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: 0;
z-index: 1030;
width: auto;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<CContainer>
<CRow>
<CCol>
<form class="form-inline ">
<label class="my-1 mr-2" for="selectChoices">Show</label>
<select class="custom-select my-1 mr-sm-2 " id="selectChoices" v-model="this.page_size" @change="this.setPage(this.page)">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<label class="my-1 mr-2" for="selectChoices">entries</label>
</form>
</CCol>
<CCol class="d-flex justify-content-end">
<form class="form-inline">
<label for="searchText" class="my-1 mr-2">Search</label>
<input type="text" id="searchText" class="form-control my-1 mr-sm-2" v-model="search_text" @keyup="this.debounceInput()">
</form>
</CCol>
</CRow>
<CRow>
<caption>
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
Mark selected issues as:
<select name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
<option value="choose">Choose...</option>
<option value="mark_read">Read</option>
<option value="mark_unread">Un-Read</option>
</select>
</caption>
</CRow>
<CRow>
<CTable striped bordered>
<CTableHead>
<CTableRow>
<CTableHeaderCell scope="col"><input class="form-check-input m-0 position-relative mt-1" type="checkbox" value="" ref="select-all"></CTableHeaderCell>
<CTableHeaderCell scope="col"></CTableHeaderCell>
<CTableHeaderCell scope="col">Comic</CTableHeaderCell>
<CTableHeaderCell scope="col">Date Added</CTableHeaderCell>
<CTableHeaderCell scope="col">status</CTableHeaderCell>
</CTableRow>
</CTableHead>
<CTableBody>
<template v-for="item in comics" :key="item.id">
<CTableRow>
<CTableHeaderCell scope="row"><input ref="comic_selector" class="form-check-input m-0 position-relative mt-1" type="checkbox" :value="item.selector"></CTableHeaderCell>
<CTableDataCell class=""><font-awesome-icon icon='book' class="" /></CTableDataCell>
<CTableDataCell><router-link :to="{name: 'read', params: { selector: item.selector }}" class="" >{{ item.file_name }}</router-link></CTableDataCell>
<CTableDataCell>{{ timeago(item.date_added) }}</CTableDataCell>
<CTableDataCell>{{ get_status(item) }}</CTableDataCell>
</CTableRow>
</template>
</CTableBody>
</CTable>
</CRow>
<CRow>
<CCol>
Showing page {{ this.page }} of {{ this.page_count }} pages.
</CCol>
<CCol class="d-flex justify-content-end">
<paginate
v-model="this.page"
:page-count="this.page_count"
:click-handler="this.setPage"
:prev-text="'Prev'"
:next-text="'Next'"
:container-class="'pagination '"
>
</paginate>
</CCol>
</CRow>
</CContainer>
</template>
<script>
import api from "@/api";
import * as timeago from 'timeago.js';
import Paginate from "vuejs-paginate-next";
export default {
name: "TheRecentTable",
components: {
Paginate
},
data () {
return {
page: 1,
page_size: 10,
page_count: 1,
search_text: '',
comics: [],
timeout: null,
func_selected: 'choose',
feed_id: ''
}},
computed: {
},
methods: {
updateComicList () {
let comic_list_url = '/api/recent/'
let params = { params: { page: this.page, page_size: this.page_size } }
if (this.search_text) {
params.params.search_text = this.search_text
}
api.get(comic_list_url, params)
.then(response => {
this.comics = response.data.results
this.page_count = Math.ceil(response.data.count / this.page_size)
})
.catch((error) => {
if (error.response.data.detail === 'Invalid page.') {
this.setPage(1)
} else {
console.log(error)
}
})
},
timeago(input) {
return timeago.format(input)
},
get_status(item) {
if (item.unread || item.unread === null) {
return "Unread"
} else if (item.finished) {
return "Finished"
} else {
return item.last_read_page + 1 + ' / ' + item.total_pages
}
},
setPage(page) {
this.page = page
this.updateComicList()
},
debounceInput() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.setPage(this.page)
}, 500)
},
performFunction() {
let selected_ids = []
this.$refs.comic_selector.forEach((selector) => {
if (selector.checked){
selected_ids.push(selector.value)
}
})
if (this.func_selected === 'mark_read') {
let comic_mark_read = '/api/action/mark_read/'
const payload = { selectors: selected_ids }
api.put(comic_mark_read, payload).then(() => {
this.updateComicList()
this.func_selected = "choose"
})
} else if (this.func_selected === 'mark_unread') {
let comic_mark_unread = '/api/action/mark_unread/'
const payload = { selectors: selected_ids }
api.put(comic_mark_unread, payload).then(() => {
this.updateComicList()
this.func_selected = "choose"
})
} else {
this.func_selected = 'choose'
}
}
},
mounted() {
this.updateComicList()
let comic_mark_unread = '/api/account/feed_id/'
api.get(comic_mark_unread).then((response) => {
this.feed_id = response.data.feed_id
})
},
}
</script>
<style scoped>
.pagination {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<CContainer>
<CForm @submit="saveForm">
<CFormInput
type="text"
label="Username"
readonly
v-model="username"
/>
<CFormInput
type="email"
label="Email address"
:placeholder="user.email"
text="Must be 8-20 characters long."
v-model="email"
feedback-invalid="Email address invalid."
/>
<CFormSelect
aria-label="Default select example"
v-model="classification"
:options="[...this.$store.state.classifications]">
</CFormSelect>
<CRow class="mt-2">
<CCol>
<CButton color="primary" type="submit" class="mr-5">Save</CButton>
<confirm-button class="mr-5" label="Reset Password" :action="resetPassword" />
<confirm-button label="Delete User" :action="deleteUser" />
</CCol>
</CRow>
</CForm>
</CContainer>
</template>
<script>
import api from "@/api";
import ConfirmButton from "@/components/ConfirmButton";
import router from "@/router";
export default {
name: "UserEdit",
components: {ConfirmButton},
data () {
return {
username: '',
email: '',
classification: '0',
new_password: null,
}
},
props: {
user: Object,
},
methods: {
saveForm () {
if (this.email !== this.user.email){
let payload = {
username: this.username,
email: this.email
}
api.patch('/api/users/'+ this.user.id + '/', payload).then(response => {
this.$emit('add-message',{
color: 'success',
text: 'Email address now set to "' + response.data.email + '"'
})
})
}
if (this.classification !== this.user.classification.toString()){
let payload = {
username: this.username,
classification: this.classification
}
api.patch('/api/users/' + this.user.id + '/set_classification/', payload).then(response => {
this.$emit('add-message', {
color: 'success',
text: 'Classification Limit now set to "' + this.$store.state.classifications.find(i => i.value === response.data.classification.toString()).label + '"'
})
})
}
},
resetPassword() {
let payload = {
username: this.username
}
api.patch('/api/users/' + this.user.id + '/reset_password/', payload).then(response => {
this.$emit('add-message', {
color: 'success',
text: 'Password reset with new password "' + response.data.password + '"'
})
this.new_password = response.data.password
})
},
deleteUser() {
api.delete('/api/users/' + this.user.id + '/').then(() => {
this.$emit('add-message', {
color: 'danger',
text: 'User "' + this.username + '" has been deleted.'
})
router.push({name: 'user'})
})
}
},
beforeUnmount() {
this.new_password = null
},
mounted() {
this.username = this.user.username
this.email = this.user.email
this.classification = this.user.classification.toString()
}
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<CTable striped bordered>
<CTableHead>
<CTableRow>
<CTableHeaderCell scope="col">#</CTableHeaderCell>
<CTableHeaderCell scope="col">Username</CTableHeaderCell>
<CTableHeaderCell scope="col">Email</CTableHeaderCell>
<CTableHeaderCell scope="col">Superuser</CTableHeaderCell>
<CTableHeaderCell scope="col">Classification</CTableHeaderCell>
</CTableRow>
</CTableHead>
<CTableBody>
<template v-for="item in users" :key="item.id">
<CTableRow>
<CTableHeaderCell scope="row">{{ item.id }}</CTableHeaderCell>
<CTableDataCell class="">
<router-link :to="{'name': 'user', params: { userid: item.id }}">{{ item.username }}</router-link>
</CTableDataCell>
<CTableDataCell>{{ item.email }}</CTableDataCell>
<CTableDataCell>{{ item.is_superuser }}</CTableDataCell>
<CTableDataCell>{{ this.$store.state.classifications.find(i => i.value === item.classification.toString()).label }}</CTableDataCell>
</CTableRow>
</template>
</CTableBody>
</CTable>
</template>
<script>
export default {
name: "UserList",
props: {
users: Object
}
}
</script>
<style scoped>
</style>

29
frontend/src/main.js Normal file
View File

@@ -0,0 +1,29 @@
import * as Vue from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue-toast-notification';
import CoreuiVue from '@coreui/vue';
import '@coreui/coreui/dist/css/coreui.min.css'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'vue-toast-notification/dist/theme-default.css';
/* import the fontawesome core */
import { library } from '@fortawesome/fontawesome-svg-core'
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
/* import specific icons */
import {faBook, faBookOpen, faEdit, faTurnUp} from '@fortawesome/free-solid-svg-icons'
library.add(faBook, faBookOpen, faEdit, faTurnUp)
import router from './router'
import store from './store'
Vue.createApp(App)
.use(CoreuiVue)
.use(ToastPlugin)
.use(store)
.use(router)
.component('font-awesome-icon', FontAwesomeIcon)
.mount('#app')

View File

@@ -0,0 +1,66 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const ReadView = () => import('@/views/ReadView')
const RecentView = () => import('@/views/RecentView')
const AccountView = () => import('@/views/AccountView')
const BrowseView = () => import('@/views/BrowseView')
const UserView = () => import('@/views/UserView')
const LoginView = () => import('@/views/LoginView')
const routes = [
{
path: '/',
name: 'home',
redirect: () => {
return { name: 'browse' }
}
},
{
path: '/browse/:selector?',
name: 'browse',
component: BrowseView,
props: true
},
{
path: '/read/:selector',
name: 'read',
component: ReadView,
props: true
},
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/recent',
name: 'recent',
component: RecentView
},
{
path: '/account',
name: 'account',
component: AccountView
},
{
path: '/user/:userid?',
name: 'user',
component: UserView,
props: true
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
}
]
const router = createRouter({
history: createWebHashHistory(process.env.BASE_URL),
routes
})
export default router

117
frontend/src/store/index.js Normal file
View File

@@ -0,0 +1,117 @@
import { createStore } from 'vuex'
import axios from 'axios'
import jwtDecode from 'jwt-decode'
import {useToast} from "vue-toast-notification";
import router from "@/router";
import api from "@/api";
function get_jwt_from_storage(){
try {
return JSON.parse(localStorage.getItem('t'))
} catch {
return null
}
}
function get_user_from_storage(){
try {
return JSON.parse(localStorage.getItem('u'))
} catch {
return null
}
}
export default createStore({
state: {
jwt: get_jwt_from_storage(),
filters: {},
user: get_user_from_storage(),
classifications: [
{label: 'G', value: '0'},
{label: 'PG', value: '1'},
{label: '12', value: '2'},
{label: '15', value: '3'},
{label: '18', value: '4'},
],
},
getters: {
is_superuser (state) {
if (state.user === null){
return false
} else {
return state.user.is_superuser
}
}
},
mutations: {
updateToken(state, newToken){
localStorage.setItem('t', JSON.stringify(newToken));
state.jwt = newToken;
},
logOut(state){
localStorage.removeItem('t');
localStorage.removeItem('u')
state.jwt = null;
state.user = null
},
updateUser(state, userData){
localStorage.setItem('u', JSON.stringify(userData));
state.user = userData
},
},
actions: {
obtainToken(context, {username, password}){
const payload = {
username: username,
password: password
}
axios.post('/api/token/', payload)
.then((response)=>{
context.commit('updateToken', response.data);
api.get('/api/account').then(response => {
context.commit('updateUser', response.data)
})
if ('next' in router.currentRoute.value.query) {
router.push(router.currentRoute.value.query.next)
} else {
router.push('/')
}
})
.catch((error)=>{
// console.log(error);
const $toast = useToast();
$toast.error(error.response.data.detail, {position:'top'});
})
},
refreshToken(){
const payload = {
refresh: this.state.jwt.refresh
}
return axios.post('/api/token/refresh/', payload)
.then((response)=>{
this.commit('updateToken', response.data)
})
.catch((error)=>{
console.log(error)
// router.push({name: 'login', query: {area: 'store'}})
})
},
inspectToken(){
const token = this.state.jwt;
if(token){
const decoded = jwtDecode(token);
const exp = decoded.exp
const orig_iat = decoded.iat
if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){
this.dispatch('refreshToken')
} else if (exp -(Date.now()/1000) < 1800){
// DO NOTHING, DO NOT REFRESH
} else {
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
}
}
}
},
modules: {
}
})

View File

@@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<template>
<the-breadcrumbs :manual_crumbs="this.crumbs" />
<the-account-form />
</template>
<script>
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
import TheAccountForm from "@/components/TheAccountForm";
export default {
name: "AccountView",
components: {TheAccountForm, TheBreadcrumbs},
data () {
return {
crumbs: [
{id: 0, selector: '', name: 'Home'},
{id: 1, selector: '', name: 'Account'}
]
}},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,20 @@
<template>
<the-breadcrumbs :selector="selector"/>
<the-comic-list :selector="selector" :key="selector" />
</template>
<script>
import TheComicList from "@/components/TheComicList";
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
export default {
name: 'BrowseView',
components: {
TheBreadcrumbs,
TheComicList,
},
props: {
selector: String
}
}
</script>

View File

@@ -0,0 +1,68 @@
<template>
<CContainer>
<CRow v-if="!initialSetupRequired">
<CCol lg="4"/>
<CCol lg="4" id="login-col">
<CForm @submit="login">
<CFormInput
type="username"
id="username"
label="Username"
placeholder="username"
text="Please enter your username"
aria-describedby="loginFormControlInputHelpInline"
v-model="username"
/>
<CFormInput
type="password"
id="password"
label="password"
placeholder="password"
text="Please enter your password"
aria-describedby="loginFormControlInputHelpInline"
v-model="password"
@keyup.enter="login"
/>
<CButton color="primary" class="mb-3">Login</CButton>
</CForm>
</CCol>
</CRow>
<CRow>
<initial-setup v-if="initialSetupRequired" />
</CRow>
</CContainer>
</template>
<script>
import InitialSetup from "@/components/InitialSetup";
import axios from "axios";
export default {
name: "LoginView",
components: {InitialSetup},
data() {
return {
username: '',
password: '',
password_alert: false,
initialSetupRequired: false
}
},
methods: {
login () {
this.$store.dispatch("obtainToken", {username: this.username, password: this.password})
}
},
mounted() {
axios.get('/api/initial_setup/required/').then(response => {
if (response.data.required){
this.initialSetupRequired = true
}
})
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,52 @@
<template>
<the-breadcrumbs :selector="selector" />
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
<the-pdf-reader :selector="selector" v-if="pdf_loaded" :key="selector" />
</template>
<script>
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
import TheComicReader from "@/components/TheComicReader";
import api from "@/api";
import ThePdfReader from "@/components/ThePdfReader";
export default {
name: "ReadView",
components: {ThePdfReader, TheComicReader, TheBreadcrumbs},
props: {
selector: String
},
data () {
return {
comic_data: {},
comic_loaded: false,
pdf_loaded: false
}
},
methods: {
updateType() {
let comic_data_url = '/api/read/' + this.selector + '/type/'
api.get(comic_data_url)
.then(response => {
if (response.data.type === 'pdf'){
this.pdf_loaded = true
this.comic_loaded = false
} else {
this.comic_loaded = true
this.pdf_loaded = false
}
})
.catch((error) => {console.log(error)})
}
},
mounted () {
this.updateType()
},
beforeUpdate() {
this.updateType()
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,24 @@
<template>
<the-breadcrumbs :manual_crumbs="this.crumbs" />
<the-recent-table />
</template>
<script>
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
import TheRecentTable from "@/components/TheRecentTable";
export default {
name: "RecentView",
components: {TheRecentTable, TheBreadcrumbs},
data () {
return {
crumbs: [
{id: 0, selector: '', name: 'Home'},
{id: 1, selector: '', name: 'Recent'}
]
}},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,81 @@
<template>
<the-breadcrumbs :manual_crumbs="this.crumbs" />
<CContainer>
<alert-messages :messages="messages" />
<user-list :users="users" v-if="!userid"/>
<user-edit v-if="user_data" :user="user_data" @add-message="addMessage"/>
<add-user v-if="!userid" @user-added="updateUsers" @add-message="addMessage"/>
</CContainer>
</template>
<script>
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
import UserList from "@/components/UserList";
import api from "@/api";
import UserEdit from "@/components/UserEdit";
import alertMessages from "@/components/AlertMessages";
import AddUser from "@/components/AddUser";
import router from "@/router";
const default_crumbs = [
{id: 0, selector: '', name: 'Home'},
{id: 1, route: {'name': 'user'}, name: 'Users'}
]
export default {
name: "UserView",
components: {AddUser, alertMessages, UserEdit, UserList, TheBreadcrumbs},
props: {
userid: String
},
data () {
return {
crumbs: [...default_crumbs],
users: [],
viewUserList: true,
user_data: null,
messages: []
}},
methods: {
updateUsers() {
api.get('/api/users/').then(response => {
this.users = response.data
})
},
getUser() {
api.get('/api/users/' + this.userid + '/').then(response => {
this.user_data = response.data
this.crumbs.push({id: 1, selector: '', name: response.data.username})
}).catch(() => {
this.messages.push({
color: 'danger',
text: 'User with id "' + this.userid + '" does not exist.'
})
router.push({name: 'user'})
})
},
addMessage(message){
this.messages.push(message)
}
},
mounted() {
this.updateUsers()
if (this.userid){
this.getUser()
}
},
beforeUpdate() {
this.updateUsers()
this.crumbs = [...default_crumbs]
if (this.userid){
this.getUser()
} else {
this.user_data = null
this.crumbs = default_crumbs
}
}
}
</script>
<style scoped>
</style>

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"declaration": false,
"esModuleInterop": true,
"experimentalDecorators": true,
"module": "es2015",
"moduleResolution": "node",
"noImplicitAny": false,
"noLib": false,
"sourceMap": true,
"strict": true,
"strictPropertyInitialization": false,
"suppressImplicitAnyIndexErrors": true,
"target": "es2015",
"baseUrl": "./src"
},
"exclude": [
"./node_modules"
],
"include": [
"./src/**/*.ts",
"./src/**/*.vue"
]
}

4
frontend/vue.config.js Normal file
View File

@@ -0,0 +1,4 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})

63
frontend/webpack.dev.js Normal file
View File

@@ -0,0 +1,63 @@
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker');
const webpack = require('webpack')
module.exports = () => {
return {
mode: 'development',
devtool: 'eval-cheap-source-map',
entry: path.resolve(__dirname, './src/main.js'),
output: {
path: path.resolve(__dirname, './dist/bundles/'),
},
module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
},
resolve: {
extensions: ['.ts', '.js', '.vue', '.json'],
alias: {
'vue': '@vue/runtime-dom',
'@': path.resolve('src'),
}
},
plugins: [
new VueLoaderPlugin(),
new BundleTracker({
filename: './webpack-stats.json',
publicPath: 'http://localhost:8080/'
}),
new webpack.DefinePlugin({
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
}),
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }),
],
devServer: {
headers: {
"Access-Control-Allow-Origin":"*"
},
hot: true,
}
};
}

66
frontend/webpack.prod.js Normal file
View File

@@ -0,0 +1,66 @@
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const webpack = require('webpack')
module.exports = (env = {}) => {
env.prod = true
return {
mode: 'production',
devtool: false,
entry: path.resolve(__dirname, './src/main.js'),
output: {
path: path.resolve(__dirname, './dist/bundles/'),
},
module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
}
]
},
resolve: {
extensions: ['.ts', '.js', '.vue', '.json'],
alias: {
'vue': '@vue/runtime-dom',
'@': path.resolve('src'),
}
},
plugins: [
new VueLoaderPlugin(),
new BundleTracker({
filename: './webpack-stats.json',
publicPath: '/static/bundles/',
integrity: true
}),
new webpack.DefinePlugin({
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
}),
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: false }),
new MiniCssExtractPlugin(),
],
optimization: {
splitChunks: {
chunks: 'all',
},
},
};
}

728
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,39 +3,47 @@ line_length = 119
[tool.poetry] [tool.poetry]
name = "cbwebreader" name = "cbwebreader"
version = "0.4.6" version = "1.0.0-alpha.1"
description = "CBR/Z Web Reader" description = "CBR/Z Web Reader"
authors = ["ajurna <ajurna@gmail.com>"] authors = ["ajurna <ajurna@gmail.com>"]
license = "Creative Commons Attribution-ShareAlike 4.0 International License" license = "Creative Commons Attribution-ShareAlike 4.0 International License"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.8" python = "^3.10"
Django = "^3.2.14" Django = "4.0.7"
gunicorn = "^20.0.4" gunicorn = "^20.0.4"
django-recaptcha2 = "^1.4.1" dj-database-url = "^1.0.0"
dj-database-url = "^0.5.0"
python-dotenv = "^0.20.0" python-dotenv = "^0.20.0"
loguru = "^0.6.0" loguru = "^0.6.0"
django-silk = "^4.1.0" django-silk = "^5.0.0"
mysqlclient = "^2.0.1" mysqlclient = "^2.0.1"
psycopg2 = "^2.8.6" psycopg2 = "^2.8.6"
rarfile = "^4.0" rarfile = "^4.0"
coverage = "^6.2" coverage = "^6.2"
django-extensions = "^3.1.3" django-extensions = "^3.2.0"
Pillow = "^9.1.1" Pillow = "^9.1.1"
django-imagekit = "^4.0.2" django-imagekit = "^4.0.2"
PyMuPDF = "~1.18" PyMuPDF = "~1.18"
django-bootstrap4 = "^22.1" django-bootstrap4 = "^22.1"
django-csp = "^3.7" django-csp = "^3.7"
django-boost = "^2.0" django-boost = "^2.0"
django-sri = "^0.3.0" django-sri = "^0.4.0"
django-node-assets = "^0.9.9" django-node-assets = "^0.9.9"
django-permissions-policy = "^4.9.0" django-permissions-policy = "^4.9.0"
djangorestframework = "^3.13.1"
Markdown = "^3.3.7"
django-filter = "^22.1"
django-cors-headers = "^3.13.0"
djangorestframework-simplejwt = "^5.2.0"
django-webpack-loader = "^1.6.0"
drf-yasg = "^1.20.0"
drf-extensions = "^0.7.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
mypy = "^0.942" mypy = "^0.971"
Werkzeug = "<2.1" Werkzeug = "<2.1"
pyOpenSSL = "^22.0.0" pyOpenSSL = "^22.0.0"
ipython = "^8.4.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

View File

@@ -1,302 +1,63 @@
asgiref==3.5.2; python_version >= "3.7" \ asgiref==3.5.2; python_version >= "3.8"
--hash=sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4 \ autopep8==1.7.0; python_version >= "3.7"
--hash=sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424 beautifulsoup4==4.11.1; python_full_version >= "3.6.0" and python_version >= "3.7"
autopep8==1.6.0; python_version >= "3.7" \ certifi==2022.6.15; python_version >= "3.7" and python_version < "4"
--hash=sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f \ charset-normalizer==2.1.1; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.0"
--hash=sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979 colorama==0.4.5; python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.5.0"
beautifulsoup4==4.11.1; python_full_version >= "3.6.0" and python_version >= "3.7" \ coreapi==2.3.3; python_version >= "3.6"
--hash=sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30 \ coreschema==0.0.4; python_version >= "3.6"
--hash=sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693 coverage==6.4.4; python_version >= "3.7"
certifi==2022.6.15; python_version >= "3.7" and python_version < "4" \ dj-database-url==1.0.0
--hash=sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412 \ django-appconf==1.0.5; python_version >= "3.6"
--hash=sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d django-boost==2.0
charset-normalizer==2.1.0; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.0" \ django-bootstrap4==22.2; python_version >= "3.7"
--hash=sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413 \ django-cors-headers==3.13.0; python_version >= "3.7"
--hash=sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5 django-csp==3.7
colorama==0.4.5; python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.5.0" \ django-extensions==3.2.0; python_version >= "3.6"
--hash=sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da \ django-filter==22.1; python_version >= "3.7"
--hash=sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4 django-imagekit==4.1.0
coverage==6.4.1; python_version >= "3.7" \ django-node-assets==0.9.11
--hash=sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b \ django-permissions-policy==4.13.0; python_version >= "3.7"
--hash=sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068 \ django-silk==5.0.1; python_version >= "3.7"
--hash=sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4 \ django-sri==0.4.0; python_version >= "3.8"
--hash=sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84 \ django-webpack-loader==1.6.0
--hash=sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad \ django==4.0.7; python_version >= "3.8"
--hash=sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc \ djangorestframework-simplejwt==5.2.0; python_version >= "3.7"
--hash=sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749 \ djangorestframework==3.13.1; python_version >= "3.6"
--hash=sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4 \ drf-extensions==0.7.1
--hash=sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df \ drf-yasg==1.21.3; python_version >= "3.6"
--hash=sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6 \ gprof2dot==2022.7.29; python_version >= "3.7"
--hash=sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46 \ gunicorn==20.1.0; python_version >= "3.5"
--hash=sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982 \ idna==3.3; python_version >= "3.7" and python_version < "4"
--hash=sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4 \ inflection==0.5.1; python_version >= "3.6"
--hash=sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb \ itypes==1.2.0; python_version >= "3.6"
--hash=sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b \ jinja2==3.1.2; python_version >= "3.7"
--hash=sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3 \ loguru==0.6.0; python_version >= "3.5"
--hash=sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6 \ markdown==3.4.1; python_version >= "3.7"
--hash=sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e \ markupsafe==2.1.1; python_version >= "3.7"
--hash=sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28 \ mysqlclient==2.1.1; python_version >= "3.5"
--hash=sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54 \ packaging==21.3; python_version >= "3.6"
--hash=sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9 \ pilkit==2.0
--hash=sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13 \ pillow==9.2.0; python_version >= "3.7"
--hash=sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9 \ psycopg2==2.9.3; python_version >= "3.6"
--hash=sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605 \ pycodestyle==2.9.1; python_version >= "3.7"
--hash=sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d \ pyjwt==2.4.0; python_version >= "3.7"
--hash=sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428 \ pymupdf==1.18.19
--hash=sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83 \ pyparsing==3.0.9; python_full_version >= "3.6.8" and python_version >= "3.6"
--hash=sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b \ python-dateutil==2.8.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7"
--hash=sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c \ python-dotenv==0.20.0; python_version >= "3.5"
--hash=sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df \ pytz==2022.2.1; python_version >= "3.7"
--hash=sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d \ rarfile==4.0
--hash=sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4 \ requests==2.28.1; python_version >= "3.7" and python_version < "4"
--hash=sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3 \ ruamel.yaml.clib==0.2.6; platform_python_implementation == "CPython" and python_version < "3.11" and python_version >= "3.6"
--hash=sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3 \ ruamel.yaml==0.17.21; python_version >= "3.6"
--hash=sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8 \ six==1.16.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7"
--hash=sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72 \ soupsieve==2.3.2.post1; python_full_version >= "3.6.0" and python_version >= "3.7"
--hash=sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264 \ sqlparse==0.4.2; python_version >= "3.8"
--hash=sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9 \ toml==0.10.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7"
--hash=sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397 \ tzdata==2022.2; sys_platform == "win32" and python_version >= "3.8"
--hash=sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815 \ ua-parser==0.16.0
--hash=sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c uritemplate==4.1.1; python_version >= "3.6"
dj-database-url==0.5.0 \ urllib3==1.26.12; python_version >= "3.7" and python_full_version < "3.0.0" and python_version < "4" or python_full_version >= "3.6.0" and python_version < "4" and python_version >= "3.7"
--hash=sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163 \ user-agents==2.2.0
--hash=sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9 win32-setctime==1.1.0; sys_platform == "win32" and python_version >= "3.5"
django-appconf==1.0.5; python_version >= "3.6" \
--hash=sha256:be3db0be6c81fa84742000b89a81c016d70ae66a7ccb620cdef592b1f1a6aaa4 \
--hash=sha256:ae9f864ee1958c815a965ed63b3fba4874eec13de10236ba063a788f9a17389d
django-boost==2.0 \
--hash=sha256:266c22a5a7bdae480cfdc337b11073e1e906521a4f9c5b3dbb7e15bfeb916065 \
--hash=sha256:6d6c2d7c34d54cfdbb6232d755967eabaaf8db5395bc003637e87a7f0e329016
django-bootstrap4==22.1; python_version >= "3.7" \
--hash=sha256:fc9984f7238fbcd330ec5111bf0435083caa7192b022eedd53bfa4128bee318f \
--hash=sha256:b6da4cb54682012ff8baa1a1e672ba30cbfae82fb3d74f4b341109074e8e239f
django-csp==3.7 \
--hash=sha256:01443a07723f9a479d498bd7bb63571aaa771e690f64bde515db6cdb76e8041a \
--hash=sha256:01eda02ad3f10261c74131cdc0b5a6a62b7c7ad4fd017fbefb7a14776e0a9727
django-extensions==3.1.5; python_version >= "3.6" \
--hash=sha256:28e1e1bf49f0e00307ba574d645b0af3564c981a6dfc87209d48cb98f77d0b1a \
--hash=sha256:9238b9e016bb0009d621e05cf56ea8ce5cce9b32e91ad2026996a7377ca28069
django-imagekit==4.1.0 \
--hash=sha256:e559aeaae43a33b34f87631a9fa5696455e4451ffa738a42635fde442fedac5c \
--hash=sha256:87e36f8dc1d8745647881f4366ef4965225f048042dacebbee0dcb87425defef
django-node-assets==0.9.11 \
--hash=sha256:df6ca9aeb868aa9692cbf8f6265132b6159798866b15ac95d7d0d4dd5f3cb6da \
--hash=sha256:4d37659c07976dc4ebccb6704051c25204e3381aa5e4f98a4a76b57e33cb1776
django-permissions-policy==4.12.0; python_version >= "3.7" \
--hash=sha256:1f10fc354110fef9270fbabbbf8d5358a6d6f5380c79f0dfba56dd41e45b1936 \
--hash=sha256:88ee9cb345898414a6ad3f2eb0d8284c67decdfc43c6fc5331531b7c39809d8c
django-recaptcha2==1.4.1 \
--hash=sha256:c0b43851b05c6bf6ebb5ecc890c13ccedacd9bb33d64b4291c74dd6fcbc89366 \
--hash=sha256:9ea90db0cec502741be1066c09ec1b8e02a73162a319a042e78e67c4605087af
django-silk==4.4.1; python_version >= "3.7" \
--hash=sha256:5d4d3f1d4e3454fb073be8928293bc8af33c61cb32987783c8f93243ebf9705e \
--hash=sha256:72f20020177e929ca5733dfebf226b4ce8559c5a7bdb1517daf9aaf7916b188a
django-sri==0.3.0; python_version >= "3.6" \
--hash=sha256:961e316c0663d2b277a60f677bae3bed451a26f045129eddf09827f98fe00b86 \
--hash=sha256:9fa50b4b41b4cc3e8072d1bc4a60a81e38fd95698aed115d2f56f3d7e83a6877
django==3.2.15; python_version >= "3.6" \
--hash=sha256:a8681e098fa60f7c33a4b628d6fcd3fe983a0939ff1301ecacac21d0b38bad56 \
--hash=sha256:677182ba8b5b285a4e072f3ac17ceee6aff1b5ce77fd173cc5b6a2d3dc022fcf
gprof2dot==2021.2.21; python_version >= "3.7" \
--hash=sha256:1223189383b53dcc8ecfd45787ac48c0ed7b4dbc16ee8b88695d053eea1acabf
gunicorn==20.1.0; python_version >= "3.5" \
--hash=sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e \
--hash=sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8
idna==3.3; python_version >= "3.7" and python_version < "4" \
--hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
--hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
jinja2==3.1.2; python_version >= "3.7" \
--hash=sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61 \
--hash=sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852
loguru==0.6.0; python_version >= "3.5" \
--hash=sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3 \
--hash=sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c
markupsafe==2.1.1; python_version >= "3.7" \
--hash=sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812 \
--hash=sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a \
--hash=sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e \
--hash=sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5 \
--hash=sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4 \
--hash=sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f \
--hash=sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e \
--hash=sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933 \
--hash=sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6 \
--hash=sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417 \
--hash=sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02 \
--hash=sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a \
--hash=sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37 \
--hash=sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980 \
--hash=sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a \
--hash=sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3 \
--hash=sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a \
--hash=sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff \
--hash=sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a \
--hash=sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452 \
--hash=sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003 \
--hash=sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1 \
--hash=sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601 \
--hash=sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925 \
--hash=sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f \
--hash=sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88 \
--hash=sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63 \
--hash=sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1 \
--hash=sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7 \
--hash=sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a \
--hash=sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f \
--hash=sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6 \
--hash=sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77 \
--hash=sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603 \
--hash=sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7 \
--hash=sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135 \
--hash=sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96 \
--hash=sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c \
--hash=sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247 \
--hash=sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b
mysqlclient==2.1.1; python_version >= "3.5" \
--hash=sha256:c1ed71bd6244993b526113cca3df66428609f90e4652f37eb51c33496d478b37 \
--hash=sha256:c812b67e90082a840efb82a8978369e6e69fc62ce1bda4ca8f3084a9d862308b \
--hash=sha256:0d1cd3a5a4d28c222fa199002810e8146cffd821410b67851af4cc80aeccd97c \
--hash=sha256:b355c8b5a7d58f2e909acdbb050858390ee1b0e13672ae759e5e784110022994 \
--hash=sha256:996924f3483fd36a34a5812210c69e71dea5a3d5978d01199b78b7f6d485c855 \
--hash=sha256:dea88c8d3f5a5d9293dfe7f087c16dd350ceb175f2f6631c9cf4caf3e19b7a96 \
--hash=sha256:828757e419fb11dd6c5ed2576ec92c3efaa93a0f7c39e263586d1ee779c3d782
pilkit==2.0 \
--hash=sha256:ddb30c2f0198a147e56b151476c3bb9fe045fbfd5b0a0fa2a3148dba62d1559f
pillow==9.2.0; python_version >= "3.7" \
--hash=sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb \
--hash=sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f \
--hash=sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5 \
--hash=sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c \
--hash=sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1 \
--hash=sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58 \
--hash=sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544 \
--hash=sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e \
--hash=sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28 \
--hash=sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d \
--hash=sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8 \
--hash=sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9 \
--hash=sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004 \
--hash=sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0 \
--hash=sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4 \
--hash=sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c \
--hash=sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a \
--hash=sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1 \
--hash=sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf \
--hash=sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c \
--hash=sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069 \
--hash=sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f \
--hash=sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8 \
--hash=sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b \
--hash=sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467 \
--hash=sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59 \
--hash=sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc \
--hash=sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d \
--hash=sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14 \
--hash=sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3 \
--hash=sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402 \
--hash=sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f \
--hash=sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8 \
--hash=sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff \
--hash=sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1 \
--hash=sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76 \
--hash=sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f \
--hash=sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8 \
--hash=sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc \
--hash=sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da \
--hash=sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4 \
--hash=sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c \
--hash=sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421 \
--hash=sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20 \
--hash=sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60 \
--hash=sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4 \
--hash=sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885 \
--hash=sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4 \
--hash=sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3 \
--hash=sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb \
--hash=sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be \
--hash=sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd \
--hash=sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013 \
--hash=sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490 \
--hash=sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac \
--hash=sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e \
--hash=sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927 \
--hash=sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04
psycopg2==2.9.3; python_version >= "3.6" \
--hash=sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362 \
--hash=sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca \
--hash=sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56 \
--hash=sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305 \
--hash=sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2 \
--hash=sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461 \
--hash=sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7 \
--hash=sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf \
--hash=sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126 \
--hash=sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c \
--hash=sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981
pycodestyle==2.8.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7" \
--hash=sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20 \
--hash=sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f
pymupdf==1.18.19 \
--hash=sha256:ee8cc8aadaa818c9a5e2fb2a944c99a98822a7a3bc618d9c5d32f126874c0635 \
--hash=sha256:03ebf6fce6889df4708061a499b912909ead5e7bf1066f05b94458dcf164e3c3 \
--hash=sha256:f4bc63b0696c2f276703fadf3232d7ccaa01ab7548d1541fbc3762c573d3e1b5 \
--hash=sha256:c2fc348061c14c79e546a207088ba8bf676dc8f1d302cb94cefe53d53c2a7808 \
--hash=sha256:a795f40654a98459007148fa0660de5a9e1e1e0d12dc5d56bbdea481a1ec8fbc \
--hash=sha256:c802bdc3fdf690470a490cbcbf026895bb497f8049d33df47e1c64da939d46b3 \
--hash=sha256:5044f9447686874f442a676776615b48b6a04d63a68dd99da21ad7a0efd5d13a \
--hash=sha256:7a7fc4b4069934d7663dbd2bcf698f123b9bb0c4b4e25383a549692bee56f466 \
--hash=sha256:dc2df761b59ff06d16db7a5ce6d19bcab365efd4391eac96dab30ddda3a09f87 \
--hash=sha256:af2c6934eb8dc803c958d9742c7b64aa7d6287f764844f296dfdfe9e576e4df4 \
--hash=sha256:d772141c9e007b887da54da5677e13975305bd85ae833eec35b923bed35ea9c8 \
--hash=sha256:ea96c0129d6be8a289b80e7f0a5f6842e09c3dcb568a170297afb256cc387146 \
--hash=sha256:e7f1897600f7a56073b1370a1709f68b00c8700c4ef945bcffde1a09f58c2746 \
--hash=sha256:0c43b849c1daef36dc83667726518af3178c831c053e3e946e1989b5fb477bca \
--hash=sha256:47a1588ce2296517019e8ba64279fe4cc03592d96fb1fc726ed9d0f5f01b5ef8 \
--hash=sha256:fc8e1f0c5fb54cb91ad3b630ef623834cb2e4b110c33eabccad9b8609053b20d \
--hash=sha256:230d130f22a91da0a897c6b7e9b3523d24dc512ee32568a40d0df0e2cf2562c0 \
--hash=sha256:7c0f145445b3ef8eb45794bf0f86fcd278696da5a6b879666b690e4856f5e481 \
--hash=sha256:c12e1870841a8746f023a64ef6d8a44fa02324d17252c3e182f639a9d5530261 \
--hash=sha256:69308316b031aace9e490e9010d6c5334129eed47b47804da72d5d07eb5b2f9a \
--hash=sha256:616533cff3605c22a327488b4470f9929da675b006d7a1498f8d7ed8bf91f386 \
--hash=sha256:f407dbeecdefdfd46b9df7b083da71558143859216dc9891c40ea9cd2cf49a04 \
--hash=sha256:439b972026fbe8636aed0fe9d2cabb321542fa92bc48cd4c96dbdd2508fc41ee \
--hash=sha256:ecc684e9c45bd4072f538cc42998cfda4d00f066ba009226e8a212b112d9992c
python-dateutil==2.8.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \
--hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
--hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9
python-dotenv==0.20.0; python_version >= "3.5" \
--hash=sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f \
--hash=sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938
pytz==2022.1; python_version >= "3.7" \
--hash=sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c \
--hash=sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7
rarfile==4.0 \
--hash=sha256:1094869119012f95c31a6f22cc3a9edbdca61861b805241116adbe2d737b68f8 \
--hash=sha256:67548769229c5bda0827c1663dce3f54644f9dbfba4ae86d4da2b2afd3e602a1
requests==2.28.1; python_version >= "3.7" and python_version < "4" \
--hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349 \
--hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983
six==1.16.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 \
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926
soupsieve==2.3.2.post1; python_full_version >= "3.6.0" and python_version >= "3.7" \
--hash=sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759 \
--hash=sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d
sqlparse==0.4.2; python_version >= "3.7" \
--hash=sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d \
--hash=sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae
toml==0.10.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \
--hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
--hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
ua-parser==0.15.0 \
--hash=sha256:e441c982ffe81aa7e31af40ac6bf1d39f8ad24f1d34a2d91baae415470b26e9b \
--hash=sha256:a93592ee96922b5f969bde9ae79662bdd41d041760280b099a6700264a1b7291
urllib3==1.26.9; python_version >= "3.7" and python_full_version < "3.0.0" and python_version < "4" or python_full_version >= "3.5.0" and python_version < "4" and python_version >= "3.7" \
--hash=sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14 \
--hash=sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e
user-agents==2.2.0 \
--hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26 \
--hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7
win32-setctime==1.1.0; sys_platform == "win32" and python_version >= "3.5" \
--hash=sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad \
--hash=sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2

View File

@@ -1,22 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDkzCCAnugAwIBAgIUXW6T5x/otOKb4BILa8OiFkLES0EwDQYJKoZIhvcNAQEL
BQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MB4X
DTIyMDQwMTA3MjM0OFoXDTIzMDQwMTA3MjM0OFowWTELMAkGA1UEBhMCQVUxEzAR
BgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5
IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAyjUgZvaWUFGBZg0nKTGdwR7wSWheDB9CU07oBI+3Oitb4UztJog8
+F0NT+iSXaz/axzKU/hRZgxULjBCLD9CA7HmosnSbrDQZ+rINA5KEpXgSI5DlH3D
UuF8Ow0eikdeEt0GuUPY3MbbB14j94H21AW+6+9pVmoc4w5FN46bsz/k1mZp5nj0
erp3xkSvUD/PyvfLanPcDmc88GlvGu5hqb5JIG5sI8KNjXq9MRM4dY3Q6kCFSNGk
tWKIrPSCs8h1N6odJoB0DJpm6l8+CyAwuuCW94pD2p5tEfGIRRFqRZpY45X+7jy8
uDAP8c5h4AUwwYp3qNMTNTZratcPlU3GSQIDAQABo1MwUTAdBgNVHQ4EFgQU5fhz
kgGGaZczk8MsC7fqvpP0ykcwHwYDVR0jBBgwFoAU5fhzkgGGaZczk8MsC7fqvpP0
ykcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALvOz0Xm+K8pv
7YIgKLAHQNXuGI7BrTYEuB/yNuFJ9vj6z0/ScM3OJkqYNYjJg8Z8YC/bWqr5QR/H
NdrCplhDdHrR0p0xEZwz94huu1qFLFjeDM8Ad2gaAxPd9glxdANOP0Gqxwy4oVya
8zKW26zdn0LAWPBdeynQlfmRxoszGhzCZgPB/RmP2bvfPSWxiRZjgzlIDlObbeDi
jF836kuAFGK6sIMYo87Unij4Y3Vq0DfE/0oH4d+48noUviL5EggIL25nGPrb2Pe9
zgHr2EOVHDTg+9oClWKv58k1RDsc5Llcm5PJzeYJ9xv925s5pByVzaarXx/uhW4O
XZEBrzOSfg==
-----END CERTIFICATE-----

View File

@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDKNSBm9pZQUYFm
DScpMZ3BHvBJaF4MH0JTTugEj7c6K1vhTO0miDz4XQ1P6JJdrP9rHMpT+FFmDFQu
MEIsP0IDseaiydJusNBn6sg0DkoSleBIjkOUfcNS4Xw7DR6KR14S3Qa5Q9jcxtsH
XiP3gfbUBb7r72lWahzjDkU3jpuzP+TWZmnmePR6unfGRK9QP8/K98tqc9wOZzzw
aW8a7mGpvkkgbmwjwo2Ner0xEzh1jdDqQIVI0aS1Yois9IKzyHU3qh0mgHQMmmbq
Xz4LIDC64Jb3ikPanm0R8YhFEWpFmljjlf7uPLy4MA/xzmHgBTDBineo0xM1Nmtq
1w+VTcZJAgMBAAECggEAIuh9CYl0dbU8c5O9uPzZ0Sc4MFQWOF2FI8CxiWgOJ7qo
iAKxxj8q122pCKyL6CQfjCxSOaDa3JUwSzEFm4HoMZ8aw+u3qmhX8ji0C1iULRU2
vQ/iVtZHTB2lRsSDCzg642LI8F7oJ2UlFuaHqwkCxaOgTDbupn53MjOae3bhJlaI
cn2KDHdR99BfCBLuT4r8Y+oM56yQ0TOEt65ZQqphHJE5niuUQo8HfVl7gK9DJhEo
aQjMCMBdCFC8DDJ2dAz/JKkZZcai+c+0cZyGAZF6Om8Mz/+Pvpj0v3nDM8Ff40hy
OcqzgswFbVmvLuORnwWEMHNqyrDVcNfItAuw5Vr5IQKBgQD9ws82Yr2zvytOOisG
1O/JuHELNoMqtNM/nh109ZulZaU4PQ7nAtG7bfmst/afolSy4Qpzswezu/uOX0Zl
VyDLuAGUn73/BaOvoEunfAWVktyBGvyD629EwmVHQYEG2bVeehrPyDQVDbxD3w6j
QaF42zciZ4TuCHvqfCP60YtRvQKBgQDL/d6CQX6O0JgxoznHj2vPnKFukk5bU/WD
VfAub6NKv5ZqGMpmQza8kuEHMTtIYRYgHQFHW0/lpTxXMCnHO4qsOL2Hmf3bsphy
YizCAB0wrSsoB6ByAP+QvxYr+1wsd/g421GFdWtZHqN7gjytrjPbmwhGOnr9Flhy
/4Zk+cuhfQKBgHfACecBW8JKMZZ97rYPoITSDE6dT/LEWHhKFl6OVQANYpWSgsjT
VMQdVtiCC9kzUsMDXdpRnw3bZQ+/uEm0fx6D3AMWCyQgtij3/RuxdDUsk/A9GvLq
FJ0fG4ovyELCVEucVbC+Ko3Q6Ioi5hZ2r0uIL5GFxn5J9KgoIxaG8jcFAoGAOXSw
5mlCF0GjjF+YF6BK0nggc/9beJfGUA61jq69BIHAAPQolfMaiLSqExeHxhQqYjMp
OAr9DwaiX0BelBIuNeHpaDc0bFv6WkVSq/XSQvKTdDvpshKb6Q4ZVZv/0zqbPJBx
frCa4sghbdk59AVb79/TzcwM9hoEIafdF7XP6BUCgYEA9mb1I1hTt1i0n0xpRzbi
5ORYi3dBI7o8VmvdlnDLOxljpCllrO7CgRwW+apW0fkxLRrB0W6xB3lYFzqicc+z
aMIVszUrYypn1gTcHdyuYpoItRUJofN1opHrfeZEF6MxIidyQX7ut6VlgT/ExuEr
N6IkjeDeu6NETzYHSUbCBCA=
-----END PRIVATE KEY-----