Compare commits

..
88 changed files with 12832 additions and 10440 deletions

No files matched your search

+4 -2
View File
@@ -3,14 +3,16 @@
!cbreader !cbreader
!comic !comic
!comic_auth
!static !static
!manage.py !manage.py
!pyproject.toml !pyproject.toml
!uv.lock !setup.cfg
!entrypoint.sh !entrypoint.sh
!entrypoint-cron.sh !entrypoint-cron.sh
!requirements.txt
!package-lock.json !package-lock.json
!package.json !package.json
!frontend !frontend
/frontend/node_modules /frontend/node_modules
/frontend/dist /frontend/dist
+3 -5
View File
@@ -15,10 +15,8 @@ DB_DATABASE=cbwebreader
# https://github.com/jacobian/dj-database-url # https://github.com/jacobian/dj-database-url
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE} DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}
# Path to your comics *inside* the container. Keep this as a container path. #Path to your comics.
COMIC_BOOK_VOLUME=/comics COMIC_BOOK_VOLUME=/media/plex/comics
# Host path that is mounted to the path above.
COMIC_BOOK_HOST_PATH=/media/plex/comics
STATIC_ROOT='/static' STATIC_ROOT='/static'
@@ -34,4 +32,4 @@ MEDIA_ROOT='/media'
# DJANGO_RECAPTCHA_PUBLIC_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 -5
View File
@@ -15,10 +15,8 @@ DB_DATABASE=cbwebreader
# https://github.com/jacobian/dj-database-url # https://github.com/jacobian/dj-database-url
DATABASE_URL='postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}' DATABASE_URL='postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}'
# Path to your comics *inside* the container. Keep this as a container path. # Path to your comics.
COMIC_BOOK_VOLUME=/comics COMIC_BOOK_VOLUME=/path/to/comic/folder
# Host path that is mounted to the path above (this is the one that varies per machine).
COMIC_BOOK_HOST_PATH=/path/to/comic/folder
# Usually fine tp leave as is. # Usually fine tp leave as is.
STATIC_ROOT=/static STATIC_ROOT=/static
@@ -31,4 +29,4 @@ MEDIA_ROOT='/media'
# UNRAR_TOOL = 'unrar.exe' # UNRAR_TOOL = 'unrar.exe'
# 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
+12 -8
View File
@@ -12,21 +12,25 @@ jobs:
run: curl -fsSL https://get.docker.com | sh run: curl -fsSL https://get.docker.com | sh
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- name: Set up Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v4 - name: install pip
- name: Install uv run: apt update && apt install -y python3-pip
run: pip install uv - uses: abatilo/actions-poetry@v2
# Extract version from the project metadata with:
poetry-version: "1.6.1"
- uses: actions/setup-node@v3
- uses: actions/checkout@v3
# Extract version from Poetry
- name: Get version - name: Get version
run: echo "PROJECT_VERSION=$(uv version --short)" >> $GITHUB_ENV run: echo "POETRY_VERSION=$(poetry version --short)" >> $GITHUB_ENV
- name: Build and push Docker image - name: Build and push Docker image
run: | run: |
docker build -t ajurna/cbwebreader:latest -t ajurna/cbwebreader:${{ env.PROJECT_VERSION }} . docker build -t ajurna/cbwebreader:latest -t ajurna/cbwebreader:${{ env.POETRY_VERSION }} .
docker push ajurna/cbwebreader:${{ env.PROJECT_VERSION }} docker push ajurna/cbwebreader:${{ env.POETRY_VERSION }}
docker push ajurna/cbwebreader:latest docker push ajurna/cbwebreader:latest
-17
View File
@@ -96,20 +96,3 @@ node_modules
localhost-key.pem localhost-key.pem
localhost.pem localhost.pem
webpack-stats.json webpack-stats.json
# Test artifacts
.pytest_cache/
test_comics/
test_media/
htmlcov/
.coverage
# Virtual environment created by uv
.venv/
# Tool caches
.mypy_cache/
.ruff_cache/
# Local-only test fixtures (kept on disk, not in git)
data
+13 -5
View File
@@ -2,14 +2,22 @@
# See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.com/hooks.html for more hooks
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0 rev: v4.3.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/python-poetry/poetry
rev: v0.17.0 rev: '1.6.1' # add version here
hooks: hooks:
- id: ruff - id: poetry-check
args: [ --fix ] - id: poetry-export
args: ["--without-hashes", "-o", "requirements.txt"]
- repo: https://github.com/pycqa/flake8
rev: "5.0.4"
hooks:
- id: flake8
additional_dependencies: [
'flake8-annotations==2.9.1',
]
+1 -1
View File
@@ -1,6 +1,6 @@
[MAIN] [MAIN]
max-line-length=120 max-line-length=120
ignore-paths=comic/migrations ignore-paths=.*/migrations
load-plugins = pylint_django load-plugins = pylint_django
disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring
good-names=pk good-names=pk
-1
View File
@@ -1 +0,0 @@
3.14
+8
View File
@@ -0,0 +1,8 @@
# File: .readthedocs.yaml
version: 2
# Explicitly set the version of Python and its requirements
python:
version: 3.8
install:
- requirements: docs/requirements.txt
+23 -44
View File
@@ -1,57 +1,36 @@
# --------------------------------------------------------------------------- FROM python:3.14.0a3-slim-bookworm
# Frontend build stage
# ---------------------------------------------------------------------------
FROM node:22-slim AS frontend
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM python:3.14-slim-trixie
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONFAULTHANDLER=1 \ ENV PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \ PYTHONHASHSEED=random \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 PYTHONDONTWRITEBYTECODE=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
ENV UV_PROJECT_ENVIRONMENT=/opt/venv RUN mkdir /src
ENV PATH="/opt/venv/bin:$PATH" RUN mkdir /static
# unrar lives in the non-free component; resolve the codename from the base image
# instead of hardcoding it.
RUN . /etc/os-release \
&& echo "deb http://deb.debian.org/debian ${VERSION_CODENAME} non-free non-free-firmware" \
> /etc/apt/sources.list.d/non-free.list \
&& apt update \
&& apt install -y --no-install-recommends cron unrar \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
# Lockfiles first so dependency installation is cached independently of the source.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . /src/ COPY . /src/
COPY --from=frontend /src/dist /src/frontend/dist
COPY --from=frontend /src/webpack-stats.json /src/frontend/webpack-stats.json
RUN mkdir -p /static /media RUN echo "deb http://ftp.uk.debian.org/debian bookworm non-free non-free-firmware" > /etc/apt/sources.list.d/non-free.list
RUN cat /src/cbreader/crontab >> /etc/crontab
RUN apt update \
&& apt install -y npm cron unrar libmariadb-dev libpq-dev pkg-config swig \
&& pip install --upgrade pip \
&& pip install -r requirements.txt \
&& cd frontend \
&& npm install \
&& npm run build \
&& apt remove -y npm software-properties-common pkg-config swig \
&& rm -r node_modules \
&& apt -y auto-remove \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*
RUN cat /src/cbreader/crontab >> /etc/cron.daily/cbreader
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1
CMD ["sh", "entrypoint.sh"]
+3 -2
View File
@@ -1,3 +1,4 @@
$version=uv version --short poetry export --without-hashes -f requirements.txt --output requirements.txt
$version=poetry version -s
docker build . -t ajurna/cbwebreader:beta --no-cache docker build . -t ajurna/cbwebreader:beta --no-cache
# docker push ajurna/cbwebreader:beta # docker push ajurna/cbwebreader:beta
+2 -1
View File
@@ -1,3 +1,4 @@
$version=uv version --short poetry export --without-hashes -f requirements.txt --output requirements.txt
$version=poetry version -s
docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
docker push ajurna/cbwebreader --all-tags docker push ajurna/cbwebreader --all-tags
+1 -3
View File
@@ -1,3 +1 @@
# Cron captures job output for e-mail, which is discarded without an MTA, so write to */15 * * * * python /src/manage.py scan_comics --settings=cbreader.settings.base
# the cron daemon's own stderr instead - that is what `docker logs` shows.
*/15 * * * * root /opt/venv/bin/python /src/manage.py scan_comics --settings=cbreader.settings.base >> /proc/1/fd/2 2>&1
+6 -48
View File
@@ -7,7 +7,6 @@ import os
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
from urllib.parse import quote
import dj_database_url import dj_database_url
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -38,14 +37,15 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
'drf_yasg', 'drf_yasg',
'webpack_loader', 'webpack_loader',
'bootstrap4',
"comic", "comic",
'django_extensions', 'django_extensions',
'imagekit', 'imagekit',
'django_boost', 'django_boost',
'sri',
"corsheaders", "corsheaders",
'django_filters', 'django_filters',
'rest_framework', 'rest_framework',
'rest_framework_simplejwt.token_blacklist',
# 'silk' # 'silk'
] ]
@@ -73,19 +73,8 @@ WSGI_APPLICATION = "cbreader.wsgi.application"
DATABASE_URL = os.getenv("DATABASE_URL") DATABASE_URL = os.getenv("DATABASE_URL")
# docker-compose passes DB_* variables, not a URL. Without building one from them the
# app silently falls back to sqlite inside the container, and the web and cron
# containers end up with two separate databases.
if not DATABASE_URL and os.getenv("DB_HOST"):
DATABASE_URL = "postgres://{}:{}@{}/{}".format(
quote(os.getenv("DB_USER", "postgres"), safe=""),
quote(os.getenv("DB_PASS", ""), safe=""),
os.getenv("DB_HOST"),
os.getenv("DB_DATABASE", "postgres"),
)
if DATABASE_URL: if DATABASE_URL:
DATABASES = {"default": dj_database_url.parse(DATABASE_URL, conn_max_age=500)} DATABASES = {"default": dj_database_url.config(conn_max_age=500)}
else: else:
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3")}} DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3")}}
@@ -208,8 +197,8 @@ CSP_STYLE_SRC = (
) )
CSP_IMG_SRC = ("'self'", "data:") CSP_IMG_SRC = ("'self'", "data:")
CSP_FONT_SRC = ("'self'",) CSP_FONT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "'unsafe-eval'", "'unsafe-inline'", "localhost:8080") CSP_SCRIPT_SRC = ("'self'", "'sha256-IYBrMxCTJ62EwagLTIRncEIpWwTmoXcXkqv3KZm/Wik='")
CSP_CONNECT_SRC = ("'self'", "ws://localhost:8080/ws") 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'")
@@ -241,46 +230,15 @@ REST_FRAMEWORK = {
], ],
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework_simplejwt.authentication.JWTAuthentication',
'comic.auth.CookieJWTAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
) )
} }
CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_ALL_ORIGINS = True
# Cookie based JWT authentication (see comic/auth.py). Tokens are stored in HttpOnly
# cookies so that browser initiated requests, such as the page images in the reader,
# can be authenticated - an <img> tag cannot send an Authorization header.
JWT_ACCESS_COOKIE_NAME = os.getenv('JWT_ACCESS_COOKIE_NAME', 'access_token')
JWT_REFRESH_COOKIE_NAME = os.getenv('JWT_REFRESH_COOKIE_NAME', 'refresh_token')
JWT_COOKIE_PATH = os.getenv('JWT_COOKIE_PATH', '/')
# Only mark the cookies Secure when the site is actually served over HTTPS, otherwise
# browsers drop them on plain http.
JWT_COOKIE_SECURE = os.getenv('JWT_COOKIE_SECURE', 'False') == 'True'
# Lax blocks cross-site POSTs, which is the CSRF protection for cookie authentication.
JWT_COOKIE_SAMESITE = os.getenv('JWT_COOKIE_SAMESITE', 'Lax')
SIMPLE_JWT = { SIMPLE_JWT = {
"ROTATE_REFRESH_TOKENS": True, "ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10), 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 'LEEWAY': timedelta(minutes=5),
'LEEWAY': timedelta(seconds=30),
'ALGORITHM': 'HS256',
'AUDIENCE': 'cbwebreader-users',
'ISSUER': 'cbwebreader',
}
# Renderer formats no longer include a leading dot, so the URL pattern in
# cbreader/urls.py matches the dot separately. See cbreader/urls.py.
SWAGGER_USE_COMPAT_RENDERERS = False
# Folder scans run through comic.tasks.scan_directory. The immediate backend runs the task
# in the caller, which is enough today; swapping to a queue backend later changes only
# this block, not the call sites.
TASKS = {
'default': {
'BACKEND': os.getenv('TASK_BACKEND', 'django.tasks.backends.immediate.ImmediateBackend'),
},
} }
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend') FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
+23
View File
@@ -0,0 +1,23 @@
upstream cbwebreader_django {
server cbwebreader:8000;
}
server {
listen 80;
location / {
proxy_pass http://cbwebreader_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}
location /static/ {
alias /static/;
}
location /media/ {
alias /media/;
}
}
-24
View File
@@ -1,24 +0,0 @@
"""Settings used by the test suite.
In-memory SQLite so the tests do not need a running Postgres, and a scratch comic
volume that the fixtures fill with generated archives.
"""
import os
from pathlib import Path
from cbreader.settings.base import * # noqa: F403
from cbreader.settings.base import BASE_DIR
SECRET_KEY = 'test-secret-key'
ALLOWED_HOSTS = ['*']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
COMIC_BOOK_VOLUME = Path(os.environ.get('COMIC_TEST_VOLUME', BASE_DIR / 'test_comics'))
MEDIA_ROOT = BASE_DIR / 'test_media'
+9 -10
View File
@@ -22,10 +22,10 @@ from django.views.generic import TemplateView
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
from rest_framework import permissions from rest_framework import permissions
from rest_framework.routers import DefaultRouter from rest_framework_extensions.routers import ExtendedDefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
# from rest_framework_extensions.routers import ExtendedDefaultRouter from comic import rest, feeds
from comic import auth_views, feeds, rest
schema_view = get_schema_view( schema_view = get_schema_view(
openapi.Info( openapi.Info(
@@ -39,12 +39,12 @@ schema_view = get_schema_view(
permission_classes=[permissions.AllowAny] permission_classes=[permissions.AllowAny]
) )
router = DefaultRouter() router = ExtendedDefaultRouter()
router.register(r'users', rest.UserViewSet) router.register(r'users', rest.UserViewSet)
router.register(r'browse', rest.BrowseViewSet, basename='browse') router.register(r'browse', rest.BrowseViewSet, basename='browse')
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail') router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
router.register(r'read', rest.ReadViewSet, basename='read') router.register(r'read', rest.ReadViewSet, basename='read')\
router.register(r'read/(?P<selector>[^/.]+)/image', rest.ImageViewSet, basename='image') .register(r'image', rest.ImageViewSet, basename='image', parents_query_lookups=['selector'])
router.register(r'recent', rest.RecentComicsView, basename="recent") router.register(r'recent', rest.RecentComicsView, basename="recent")
router.register(r'history', rest.HistoryViewSet, basename='history') router.register(r'history', rest.HistoryViewSet, basename='history')
router.register(r'action', rest.ActionViewSet, basename='action') router.register(r'action', rest.ActionViewSet, basename='action')
@@ -56,12 +56,11 @@ router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup')
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path("feed/<user_selector>/", feeds.RecentComicsAPI()), 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(?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'^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'), re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('api/token/', auth_views.CookieTokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', auth_views.CookieTokenRefreshView.as_view(), name='token_refresh'), path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/token/blacklist/', auth_views.CookieTokenBlacklistView.as_view(), name='token_blacklist'),
path('api/', include(router.urls)), path('api/', include(router.urls)),
path("", path("",
TemplateView.as_view(template_name="application.html"), TemplateView.as_view(template_name="application.html"),
+2 -1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from django.contrib import admin from django.contrib import admin
from .models import ComicBook, ComicStatus, Directory, UserMisc from .models import Directory, ComicBook, ComicStatus, UserMisc
@admin.register(Directory) @admin.register(Directory)
-30
View File
@@ -1,30 +0,0 @@
"""Shared helpers for listing the pages inside a comic archive."""
import mimetypes
import zipfile
from typing import NamedTuple
import rarfile
class ArchiveFile(NamedTuple):
file_name: str
mime_type: str
def get_archive_files(archive: zipfile.ZipFile | rarfile.RarFile) -> list[ArchiveFile]:
"""Return the image entries of an archive in page order.
Metadata entries such as ComicInfo.xml are not pages. Counting them would make
page_count disagree with the page list the reader uses, so a book could never
be marked as finished.
"""
result = []
for name in sorted(archive.namelist()):
if name.endswith('/'):
continue
mime_type = mimetypes.guess_type(name)[0]
if not mime_type or name.lower().endswith('.xml'):
continue
result.append(ArchiveFile(name, mime_type))
return result
-66
View File
@@ -1,66 +0,0 @@
"""Cookie based JWT authentication.
Tokens are kept in HttpOnly cookies rather than in JavaScript storage. That is
what makes browser initiated requests work: an `<img>` tag cannot send an
`Authorization` header, so protected page images can only be served through
cookie authentication.
The views live in comic/auth_views.py: importing rest_framework.views here would
resolve DEFAULT_AUTHENTICATION_CLASSES while this module is still being imported.
"""
from typing import Any
from django.conf import settings
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework_simplejwt.authentication import AUTH_HEADER_TYPES, JWTAuthentication
def access_cookie_name() -> str:
return settings.JWT_ACCESS_COOKIE_NAME
def refresh_cookie_name() -> str:
return settings.JWT_REFRESH_COOKIE_NAME
def _cookie_options() -> dict[str, Any]:
return {
'httponly': True,
'secure': settings.JWT_COOKIE_SECURE,
'samesite': settings.JWT_COOKIE_SAMESITE,
'path': settings.JWT_COOKIE_PATH,
}
def set_auth_cookies(response: Response, tokens: dict[str, Any]) -> None:
"""Write the access and refresh tokens into HttpOnly cookies."""
simple_jwt = settings.SIMPLE_JWT
for name, token, lifetime in (
(access_cookie_name(), tokens.get('access'), simple_jwt['ACCESS_TOKEN_LIFETIME']),
(refresh_cookie_name(), tokens.get('refresh'), simple_jwt['REFRESH_TOKEN_LIFETIME']),
):
if token:
response.set_cookie(name, token, max_age=int(lifetime.total_seconds()), **_cookie_options())
def clear_auth_cookies(response: Response) -> None:
for name in (access_cookie_name(), refresh_cookie_name()):
response.delete_cookie(name, path=settings.JWT_COOKIE_PATH)
class CookieJWTAuthentication(JWTAuthentication):
"""Authenticate from the Authorization header, or from the access cookie.
Keeping the header path means API clients that send a bearer token still work.
"""
def get_header(self, request: Request) -> bytes | None:
header = super().get_header(request)
if header:
return header
token = request.COOKIES.get(access_cookie_name())
if not token:
return None
return f'{AUTH_HEADER_TYPES[0]} {token}'.encode()
-72
View File
@@ -1,72 +0,0 @@
"""Token views that store JWTs in HttpOnly cookies (see comic/auth.py)."""
from rest_framework import status
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.serializers import TokenBlacklistSerializer, TokenRefreshSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
from comic.auth import clear_auth_cookies, refresh_cookie_name, set_auth_cookies
def _refresh_value(request: Request) -> str | None:
"""The refresh token from the request body, or from the refresh cookie."""
value = request.data.get('refresh') if isinstance(request.data, dict) else None
return value or request.COOKIES.get(refresh_cookie_name())
class CookieTokenObtainPairView(TokenObtainPairView):
"""Log in: issue tokens and store them in HttpOnly cookies."""
def post(self, request: Request, *args: object, **kwargs: object) -> Response:
response = super().post(request, *args, **kwargs)
if response.status_code == status.HTTP_200_OK and isinstance(response.data, dict):
set_auth_cookies(response, response.data)
# The tokens live in HttpOnly cookies, so they are not returned to JavaScript.
response.data = {}
return response
class CookieTokenRefreshView(TokenObtainPairView):
"""Refresh using the refresh cookie and update both cookies."""
serializer_class = TokenRefreshSerializer
def post(self, request: Request, *args: object, **kwargs: object) -> Response:
refresh = _refresh_value(request)
if not refresh:
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'errors': 'No refresh token.'})
serializer = self.get_serializer(data={'refresh': refresh})
try:
serializer.is_valid(raise_exception=True)
except TokenError as exc:
raise AuthenticationFailed(exc.args[0]) from exc
response = Response(status=status.HTTP_200_OK)
set_auth_cookies(response, serializer.validated_data)
# Keep the cookies out of the body so JavaScript never sees the tokens.
return response
class CookieTokenBlacklistView(TokenObtainPairView):
"""Log out: blacklist the refresh cookie and clear both cookies."""
serializer_class = TokenBlacklistSerializer
def post(self, request: Request, *args: object, **kwargs: object) -> Response:
refresh = _refresh_value(request)
if not refresh:
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'errors': 'No refresh token.'})
serializer = self.get_serializer(data={'refresh': refresh})
try:
serializer.is_valid(raise_exception=True)
except TokenError as exc:
raise AuthenticationFailed(exc.args[0]) from exc
response = Response(status=status.HTTP_204_NO_CONTENT)
clear_auth_cookies(response)
return response
+2 -2
View File
@@ -1,10 +1,10 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.db.models import Case, F, PositiveSmallIntegerField, QuerySet, When from django.db.models import Case, When, PositiveSmallIntegerField, F, QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from comic.models import ComicBook, Directory, UserMisc from comic.models import ComicBook, UserMisc, Directory
class RecentComicsAPI(Feed): class RecentComicsAPI(Feed):
View File
Whitespace-only changes.
+13 -21
View File
@@ -1,11 +1,12 @@
from pathlib import Path from typing import Optional
from django.conf import settings from django.contrib.auth import get_user_model
from django.contrib.auth.base_user import AbstractBaseUser
from django.core.management.base import BaseCommand, CommandParser from django.core.management.base import BaseCommand, CommandParser
from loguru import logger from loguru import logger
from comic.models import ComicBook, Directory from comic.models import Directory
from comic.processing import reconcile from comic.processing import generate_directory
class Command(BaseCommand): class Command(BaseCommand):
@@ -21,25 +22,16 @@ class Command(BaseCommand):
action='store_true', action='store_true',
help='Output to console', help='Output to console',
) )
parser.add_argument(
'--if-empty',
action='store_true',
help='Do nothing when the database already contains books, so it is safe to run on startup.',
)
def handle(self, *args, **options) -> None: def handle(self, *args, **options) -> None:
self.OUTPUT = options.get('out', False) self.OUTPUT = options.get('out', False)
if options.get('if_empty') and ComicBook.objects.exists():
logger.info('The database already contains books, so the startup scan is skipped.')
return
self.scan_directory() self.scan_directory()
def scan_directory(self, directory: Directory | None = None) -> None: def scan_directory(self, user: Optional[AbstractBaseUser] = None, directory: Optional[Directory] = None) -> None:
reconcile(directory) if not user:
path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME user_model = get_user_model()
if self.OUTPUT: user: AbstractBaseUser = user_model.objects.first()
self.stdout.write(str(path)) for item in generate_directory(user, directory):
else: if item is Directory:
logger.info(path) logger.info(item)
for child in Directory.objects.filter(parent=directory): self.scan_directory(user, item)
self.scan_directory(child)
+2 -1
View File
@@ -7,7 +7,8 @@ import uuid
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
utc = datetime.timezone.utc from django.utils.timezone import utc
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -1,38 +0,0 @@
# Generated by Django 6.1.2 on 2026-10-13 12:51
from collections import defaultdict
from django.db import migrations, models
def merge_duplicate_directories(apps, schema_editor) -> None:
"""Scans could insert the same directory twice, so fold duplicates together
before the unique constraint is added, otherwise the migration fails on data
the bug has already created."""
Directory = apps.get_model('comic', 'Directory')
ComicBook = apps.get_model('comic', 'ComicBook')
groups = defaultdict(list)
for directory in Directory.objects.order_by('id'):
groups[(directory.parent_id, directory.name)].append(directory)
for group in groups.values():
keeper = group[0]
for duplicate in group[1:]:
ComicBook.objects.filter(directory=duplicate).update(directory=keeper)
duplicate.delete()
class Migration(migrations.Migration):
dependencies = [
('comic', '0050_delete_comicpage'),
]
operations = [
migrations.RunPython(merge_duplicate_directories, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='directory',
constraint=models.UniqueConstraint(fields=('parent', 'name'), name='one_directory_name_per_parent'),
),
]
+43 -53
View File
@@ -1,13 +1,16 @@
import io import io
import mimetypes
import uuid import uuid
import zipfile import zipfile
from functools import reduce from functools import reduce
from pathlib import Path from pathlib import Path
from typing import IO, Final from typing import Optional, List, Union, Tuple, Final, IO
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import pymupdf import fitz
import rarfile import rarfile
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile
@@ -16,10 +19,7 @@ from django.db.models import UniqueConstraint
from django_boost.models.fields import AutoOneToOneField from django_boost.models.fields import AutoOneToOneField
from imagekit.models import ProcessedImageField from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFill from imagekit.processors import ResizeToFill
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive from comic.errors import NotCompatibleArchive
if settings.UNRAR_TOOL: if settings.UNRAR_TOOL:
@@ -50,13 +50,9 @@ class Directory(models.Model):
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
constraints = [
models.UniqueConstraint(fields=['parent', 'name'], name='one_directory_name_per_parent'),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"Directory: {self.name}; {self.parent}"
return f"Directory: {self.name}: {self.parent}"
@property @property
def title(self) -> str: def title(self) -> str:
@@ -67,7 +63,7 @@ class Directory(models.Model):
return 'Directory' return 'Directory'
def generate_thumbnail(self) -> None: def generate_thumbnail(self) -> None:
book: ComicBook|None = ComicBook.objects.filter(directory=self).order_by('file_name').first() book: ComicBook = ComicBook.objects.filter(directory=self).order_by('file_name').first()
if not book: if not book:
return return
if not book.thumbnail: if not book.thumbnail:
@@ -87,7 +83,7 @@ class Directory(models.Model):
return reduce(lambda x, y: Path(x, y), path_items) return reduce(lambda x, y: Path(x, y), path_items)
return Path(path_items[0]) return Path(path_items[0])
def get_path_items(self, path_items: list | None = None) -> list[Path]: def get_path_items(self, path_items: Optional[List] = None) -> List[Path]:
if path_items is None: if path_items is None:
path_items = [] path_items = []
path_items.append(self.name) path_items.append(self.name)
@@ -95,7 +91,7 @@ class Directory(models.Model):
self.parent.get_path_items(path_items) self.parent.get_path_items(path_items)
return path_items return path_items
def get_path_objects(self, path_items: list | None = None) -> list[Directory]: def get_path_objects(self, path_items: Optional[List] = None) -> List["Directory"]:
if path_items is None: if path_items is None:
path_items = [] path_items = []
path_items.append(self) path_items.append(self)
@@ -144,43 +140,29 @@ class ComicBook(models.Model):
return Path(base_dir, self.directory.get_path(), self.file_name) return Path(base_dir, self.directory.get_path(), self.file_name)
return Path(base_dir, self.file_name) return Path(base_dir, self.file_name)
def get_image(self, page: int) -> tuple[IO[bytes], str] | tuple[bool, bool]: def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]:
if self.file_name.lower().endswith('.pdf'): base_dir = settings.COMIC_BOOK_VOLUME
# noinspection PyUnresolvedReferences if self.directory:
doc = pymupdf.open(self.get_pdf()) archive_path = Path(base_dir, self.directory.path, self.file_name)
page: pymupdf.Page = doc[page]
pix = page.get_pixmap()
mode: Final = "RGBA" if pix.alpha else "RGB"
# noinspection PyTypeChecker
pil_data = Image.frombytes(mode, (pix.width, pix.height), pix.samples)
img = io.BytesIO()
pil_data.save(img, format="PNG")
img.seek(0)
return img, "Image/PNG"
else: else:
base_dir = settings.COMIC_BOOK_VOLUME archive_path = Path(base_dir, self.file_name)
if self.directory: try:
archive_path = Path(base_dir, self.directory.path, self.file_name) archive = rarfile.RarFile(archive_path)
else: except rarfile.NotRarFile:
archive_path = Path(base_dir, self.file_name) # pylint: disable=consider-using-with
try: archive = zipfile.ZipFile(archive_path)
archive = rarfile.RarFile(archive_path) except zipfile.BadZipfile:
except rarfile.NotRarFile: return False, False
# pylint: disable=consider-using-with
archive = zipfile.ZipFile(archive_path)
except zipfile.BadZipfile:
return False, False
file_name, file_mime = get_archive_files(archive)[page] file_name, file_mime = self.get_archive_files(archive)[page]
return archive.open(file_name), file_mime return archive.open(file_name), file_mime
def generate_thumbnail_pdf(self, page_index: int = 0) -> tuple[io.BytesIO, Image_type, str]: def generate_thumbnail_pdf(self, page_index: int = 0) -> Tuple[io.BytesIO, Image_type, str]:
img, pil_data = self._get_pdf_image(page_index if page_index else 0) img, pil_data = self._get_pdf_image(page_index if page_index else 0)
return img, pil_data, 'Image/JPEG' return img, pil_data, 'Image/JPEG'
def generate_thumbnail_archive( def generate_thumbnail_archive(self, page_index: int = 0) -> Union[Tuple[io.BytesIO, Image_type, str],
self, page_index: int = 0 Tuple[bool, bool, bool]]:
) -> tuple[io.BytesIO, Image_type, str] | tuple[bool, bool, bool]:
img, content_type, pil_data = False, False, False img, content_type, pil_data = False, False, False
if page_index: if page_index:
img, content_type = self.get_image(page_index) img, content_type = self.get_image(page_index)
@@ -213,8 +195,9 @@ class ComicBook(models.Model):
) )
self.save() self.save()
def _get_pdf_image(self, page_index: int) -> tuple[io.BytesIO, Image_type]: def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]:
doc = pymupdf.open(self.get_pdf()) # noinspection PyUnresolvedReferences
doc = fitz.open(self.get_pdf())
page = doc[page_index] page = doc[page_index]
pix = page.get_pixmap() pix = page.get_pixmap()
mode: Final = "RGBA" if pix.alpha else "RGB" mode: Final = "RGBA" if pix.alpha else "RGB"
@@ -226,7 +209,7 @@ class ComicBook(models.Model):
return img, pil_data return img, pil_data
@staticmethod @staticmethod
def process_comic_book(comic_file_path: Path, directory: Directory = False) -> ComicBook | Path: def process_comic_book(comic_file_path: Path, directory: "Directory" = False) -> Union["ComicBook", Path]:
try: try:
book = ComicBook.objects.get(file_name=comic_file_path.name, version=0) book = ComicBook.objects.get(file_name=comic_file_path.name, version=0)
book.directory = directory book.directory = directory
@@ -244,7 +227,7 @@ class ComicBook(models.Model):
return comic_file_path return comic_file_path
if archive_type == 'archive': if archive_type == 'archive':
book.page_count = len(get_archive_files(archive)) book.page_count = len(book.get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
book.page_count = archive.page_count book.page_count = archive.page_count
@@ -256,7 +239,7 @@ class ComicBook(models.Model):
return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name) return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name)
return Path(settings.COMIC_BOOK_VOLUME, self.file_name) return Path(settings.COMIC_BOOK_VOLUME, self.file_name)
def get_archive(self) -> tuple[rarfile.RarFile | zipfile.ZipFile | pymupdf.Document, str]: def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, fitz.Document], str]:
archive_path = self.get_archive_path archive_path = self.get_archive_path
try: try:
return rarfile.RarFile(archive_path), 'archive' return rarfile.RarFile(archive_path), 'archive'
@@ -269,7 +252,7 @@ class ComicBook(models.Model):
try: try:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
return pymupdf.open(str(archive_path)), 'pdf' return fitz.open(str(archive_path)), 'pdf'
except RuntimeError: except RuntimeError:
pass pass
raise NotCompatibleArchive raise NotCompatibleArchive
@@ -277,10 +260,17 @@ class ComicBook(models.Model):
def get_page_count(self) -> int: def get_page_count(self) -> int:
archive, archive_type = self.get_archive() archive, archive_type = self.get_archive()
if archive_type == 'archive': if archive_type == 'archive':
return len(get_archive_files(archive)) return len(self.get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
return archive.page_count return archive.page_count
@staticmethod
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[Tuple[str, str]]:
return [
(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
if not x.endswith('/') and mimetypes.guess_type(x)[0] and not x.endswith('xml')
]
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)
@@ -301,8 +291,8 @@ class ComicStatus(models.Model):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<ComicStatus: {self.user.username}: {self.comic.file_name}: {self.last_read_page}: " f"<ComicStatus:{self.user.username}:{self.comic.file_name}:{self.last_read_page}:"
f"{self.unread}: {self.finished}" f"{self.unread}:{self.finished}"
) )
+59 -58
View File
@@ -1,59 +1,49 @@
from collections.abc import Iterable import mimetypes
import zipfile
from itertools import chain
from pathlib import Path from pathlib import Path
from typing import NamedTuple, List, Optional, Union
import rarfile
from django.conf import settings from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import AbstractBaseUser
from django.db.models import ( from django.db.models import Count, Q, F, Case, When, PositiveSmallIntegerField, QuerySet, ExpressionWrapper, \
BooleanField, IntegerField
Case,
Count,
ExpressionWrapper,
F,
IntegerField,
PositiveSmallIntegerField,
Q,
QuerySet,
Value,
When,
)
from django.db.models.functions import Coalesce
from comic import models from comic import models
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive from comic.errors import NotCompatibleArchive
def reconcile(directory: models.Directory | None = None) -> None: def generate_directory(user: AbstractBaseUser, directory: Optional[models.Directory] = None) \
"""Bring the database in line with the filesystem for one directory. -> List[Union[models.Directory, models.ComicBook]]:
This is the only place the scan writes, so it runs from the scan task and the
management command - never from a browse request.
"""
dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
files = []
clean_directories(models.Directory.objects.filter(parent=directory), dir_path, directory) dir_db_query = models.Directory.objects.filter(parent=directory)
clean_files(models.ComicBook.objects.filter(directory=directory), dir_path, directory) clean_directories(dir_db_query, dir_path, directory)
clean_thumbnails(models.Directory.objects.filter(parent=directory))
clean_thumbnails(models.ComicBook.objects.filter(directory=directory))
file_db_query = models.ComicBook.objects.filter(directory=directory)
clean_files(file_db_query, user, dir_path, directory)
def list_directory(user: AbstractBaseUser, directory: models.Directory | None = None) \ dir_db_query = dir_db_query.annotate(
-> list[models.Directory | models.ComicBook]:
"""Return the contents of one directory for one user, reading only."""
dir_db_query = models.Directory.objects.filter(parent=directory).annotate(
total=Count('comicbook', distinct=True), total=Count('comicbook', distinct=True),
progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True, progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True,
comicbook__comicstatus__user=user), distinct=True), comicbook__comicstatus__user=user), distinct=True),
finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()), finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()),
unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()), unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()),
) )
files.extend(dir_db_query)
# A book with no status for this user is simply unread, so the annotations have to # Create Missing Status
# stand in for the missing row instead of assuming one exists. new_status = [models.ComicStatus(comic=file, user=user) for file in
file_db_query = models.ComicBook.objects.filter(directory=directory).annotate( file_db_query.exclude(comicstatus__in=models.ComicStatus.objects.filter(
progress=Coalesce(F('comicstatus__last_read_page') + 1, Value(0, output_field=IntegerField())), comic__in=file_db_query, user=user))]
finished=Coalesce(F('comicstatus__finished'), Value(False, output_field=BooleanField())), models.ComicStatus.objects.bulk_create(new_status)
unread=Coalesce(F('comicstatus__unread'), Value(True, output_field=BooleanField())),
file_db_query = file_db_query.annotate(
progress=F('comicstatus__last_read_page') + 1,
finished=F('comicstatus__finished'),
unread=F('comicstatus__unread'),
user=F('comicstatus__user'), user=F('comicstatus__user'),
classification=Case( classification=Case(
When(directory__isnull=True, then=models.Directory.Classification.C_G), When(directory__isnull=True, then=models.Directory.Classification.C_G),
@@ -62,57 +52,68 @@ def list_directory(user: AbstractBaseUser, directory: models.Directory | None =
) )
).filter(Q(user__isnull=True) | Q(user=user.id)) ).filter(Q(user__isnull=True) | Q(user=user.id))
files = list(dir_db_query) + list(file_db_query) 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.title)
files.sort(key=lambda x: x.type, reverse=True) files.sort(key=lambda x: x.type, reverse=True)
return files return files
def clean_directories(directories: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None: def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional[models.Directory] = None) -> None:
dir_db_set = {Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories} dir_db_set = set(Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories)
dir_list = {x for x in sorted(dir_path.glob('*')) if x.is_dir()} dir_list = set(x for x in sorted(dir_path.glob('*')) if x.is_dir())
# Create new directories db instances # Create new directories db instances
for new_directory in dir_list - dir_db_set: for new_directory in dir_list - dir_db_set:
# get_or_create, not save(): another request may have inserted the same directory models.Directory(name=new_directory.name, parent=directory).save()
# between the query above and this line, which would leave duplicate rows.
models.Directory.objects.get_or_create(name=new_directory.name, parent=directory)
# Remove stale db instances # Remove stale db instances
for stale_directory in dir_db_set - dir_list: for stale_directory in dir_db_set - dir_list:
models.Directory.objects.filter(name=stale_directory.name, parent=directory).delete() models.Directory.objects.get(name=stale_directory.name, parent=directory).delete()
def clean_files(files: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None: def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: Optional[models.Directory] = None) \
file_list = {x for x in sorted(dir_path.glob('*')) if x.is_file()} -> None:
files_db_set = {Path(dir_path, x.file_name) for x in files} 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 # Parse new comics
books_to_add = [] books_to_add = []
for new_comic in file_list - files_db_set: for new_comic in file_list - files_db_set:
if new_comic.suffix.lower() in settings.SUPPORTED_FILES: if new_comic.suffix.lower() in settings.SUPPORTED_FILES:
new_book = models.ComicBook(file_name=new_comic.name, directory=directory) new_book = models.ComicBook(file_name=new_comic.name, directory=directory)
archive, archive_type = new_book.get_archive()
try: try:
archive, archive_type = new_book.get_archive()
if archive_type == 'archive': if archive_type == 'archive':
new_book.page_count = len(get_archive_files(archive)) new_book.page_count = len(get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
new_book.page_count = archive.page_count new_book.page_count = archive.page_count
except NotCompatibleArchive: except NotCompatibleArchive:
# Unreadable/corrupt archive - index the file with no page count
pass pass
books_to_add.append(new_book) books_to_add.append(new_book)
models.ComicBook.objects.bulk_create(books_to_add)
# ignore_conflicts: a concurrent scan may have indexed the same file, and status_to_add = []
# (directory, file_name) is unique. for book in books_to_add:
models.ComicBook.objects.bulk_create(books_to_add, ignore_conflicts=True) status_to_add.append(models.ComicStatus(user=user, comic=book))
models.ComicStatus.objects.bulk_create(status_to_add)
# Remove stale comic instances # Remove stale comic instances
for stale_comic in files_db_set - file_list: for stale_comic in files_db_set - file_list:
models.ComicBook.objects.filter(file_name=stale_comic.name, directory=directory).delete() models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete()
def clean_thumbnails(items: Iterable[models.Directory | models.ComicBook]) -> None: class ArchiveFile(NamedTuple):
for item in items: file_name: str
if item.thumbnail and not Path(item.thumbnail.path).exists(): mime_type: str
item.thumbnail.delete()
item.save()
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> 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]
]
+92 -128
View File
@@ -1,39 +1,26 @@
import json
from collections.abc import Iterable
from http.client import HTTPResponse
from pathlib import Path from pathlib import Path
from typing import Union, Optional, Dict, Iterable, List
from uuid import UUID from uuid import UUID
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Case, F, FileField, PositiveSmallIntegerField, QuerySet, When from django.db.models import Case, When, F, PositiveSmallIntegerField, FileField, QuerySet
from django.http import FileResponse from django.http import FileResponse
from django.utils.crypto import get_random_string
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins, permissions, renderers, serializers, status, viewsets from rest_framework import viewsets, serializers, mixins, permissions, status, renderers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from comic import models, processing, tasks from comic import models
from comic.processing import generate_directory
from comic.util import generate_breadcrumbs_from_path from comic.util import generate_breadcrumbs_from_path
def user_can_read(user: models.User, directory: models.Directory | None) -> bool:
"""Whether the user's classification allowance covers this book.
Books that are not inside a directory are treated as the highest rating, which
matches how the browse API reports them.
"""
misc, _ = models.UserMisc.objects.get_or_create(user=user)
classification = directory.classification if directory else models.Directory.Classification.C_18
return classification <= misc.allowed_to_read
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read', classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
source='usermisc') source='usermisc')
@@ -72,15 +59,16 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
""" """
target_user = get_object_or_404(User, id=pk) target_user = get_object_or_404(User, id=pk)
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid() and target_user.username == serializer.data['username']: if serializer.is_valid():
password = get_random_string(12) if target_user.username == serializer.data['username']:
target_user.set_password(password) password = User.objects.make_random_password()
target_user.save() target_user.set_password(password)
resp_serializer = self.get_serializer({ target_user.save()
'username': target_user.username, resp_serializer = self.get_serializer({
'password': password 'username': target_user.username,
}) 'password': password
return Response(resp_serializer.data) })
return Response(resp_serializer.data)
return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST) return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST)
@@ -99,7 +87,7 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
class BrowseFileField(serializers.FileField): class BrowseFileField(serializers.FileField):
def to_representation(self, value: FileField | None) -> str | None: def to_representation(self, value: Optional[FileField]) -> Optional[str]:
if not value: if not value:
return None return None
return Path(settings.MEDIA_URL, value.name).as_posix() return Path(settings.MEDIA_URL, value.name).as_posix()
@@ -123,47 +111,34 @@ class BreadcrumbSerializer(serializers.Serializer):
name = serializers.CharField() name = serializers.CharField()
class ScanSerializer(serializers.Serializer):
selector = serializers.UUIDField(required=False, allow_null=True)
class BrowseViewSet(viewsets.GenericViewSet): class BrowseViewSet(viewsets.GenericViewSet):
serializer_class = BrowseSerializer serializer_class = BrowseSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector' lookup_field = 'selector'
def get_queryset(self) -> None: def get_queryset(self):
return return
def list(self, request: Request) -> Response: def list(self, request: Request) -> Response:
serializer = self.get_serializer(processing.list_directory(request.user), many=True) serializer = self.get_serializer(generate_directory(request.user), many=True)
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)}) @swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
def retrieve(self, request: Request, selector: UUID) -> Response: def retrieve(self, request: Request, selector: UUID) -> Response:
directory = get_object_or_404(models.Directory, selector=selector) directory = models.Directory.objects.get(selector=selector)
serializer = self.get_serializer(processing.list_directory(request.user, directory), many=True) serializer = self.get_serializer(generate_directory(request.user, directory), many=True)
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema(request_body=ScanSerializer, responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
@action(methods=['post'], detail=False, serializer_class=ScanSerializer)
def scan(self, request: Request) -> Response:
"""Read one directory from disk, then return it. Null selector means the root."""
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
selector = serializer.validated_data.get('selector')
tasks.scan_directory.enqueue(str(selector) if selector else None)
directory = models.Directory.objects.filter(selector=selector).first() if selector else None
data = processing.list_directory(request.user, directory)
return Response(BrowseSerializer(data, many=True).data)
@swagger_auto_schema(responses={status.HTTP_200_OK: BreadcrumbSerializer(many=True)}) @swagger_auto_schema(responses={status.HTTP_200_OK: BreadcrumbSerializer(many=True)})
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer) @action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
def breadcrumbs(self, _request: Request, selector: UUID) -> Response: def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
queryset = [] queryset = []
comic = models.ComicBook.objects.filter(selector=selector).first() comic = False
directory = comic.directory if comic else get_object_or_404(models.Directory, selector=selector) 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)): for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
queryset.append({ queryset.append({
@@ -188,17 +163,25 @@ class GenerateThumbnailViewSet(viewsets.ViewSet):
@swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()})
def retrieve(self, _request: Request, selector: UUID) -> Response: def retrieve(self, _request: Request, selector: UUID) -> Response:
try: try:
target = models.Directory.objects.get(selector=selector) 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: except models.Directory.DoesNotExist:
target = get_object_or_404(models.ComicBook, selector=selector) comic = models.ComicBook.objects.get(selector=selector)
if not target.thumbnail: if not comic.thumbnail:
target.generate_thumbnail() comic.generate_thumbnail()
return Response( return Response(
self.serializer_class({ self.serializer_class({
"selector": target.selector, "selector": comic.selector,
"thumbnail": target.thumbnail "thumbnail": comic.thumbnail
}).data }).data
) )
class DirectionSerializer(serializers.Serializer): class DirectionSerializer(serializers.Serializer):
@@ -228,12 +211,9 @@ class ReadViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector' lookup_field = 'selector'
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer(), @swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer()})
status.HTTP_403_FORBIDDEN: 'User below classification allowed'})
def retrieve(self, request: Request, selector: UUID) -> Response: def retrieve(self, request: Request, selector: UUID) -> Response:
comic = get_object_or_404(models.ComicBook, selector=selector) comic = get_object_or_404(models.ComicBook, selector=selector)
if not user_can_read(request.user, comic.directory):
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
_, _ = models.UserMisc.objects.get_or_create(user=request.user) _, _ = models.UserMisc.objects.get_or_create(user=request.user)
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user) 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_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name'))
@@ -265,22 +245,22 @@ class ReadViewSet(viewsets.GenericViewSet):
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data', @swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
status.HTTP_403_FORBIDDEN: 'User below classification allowed', status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
status.HTTP_404_NOT_FOUND: 'Comic file not found'})
@action(methods=['get'], detail=True) @action(methods=['get'], detail=True)
def pdf(self, request: Request, selector: UUID) -> FileResponse | Response | HTTPResponse: def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response]:
book = get_object_or_404(models.ComicBook, selector=selector) book = models.ComicBook.objects.get(selector=selector)
if not user_can_read(request.user, book.directory): misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'}) try:
pdf_path = book.get_pdf() if book.directory.classification > misc.allowed_to_read:
if not pdf_path.is_file(): return Response(status=400, data={'errors': 'Not allowed to read.'})
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Comic file not found.'}) except AttributeError:
return FileResponse(open(pdf_path, 'rb'), content_type='application/pdf') pass
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
@action(methods=['get'], detail=True) @action(methods=['get'], detail=True)
def type(self, _request: Request, selector: UUID) -> Response: def type(self, _request: Request, selector: UUID) -> Response:
book = get_object_or_404(models.ComicBook, selector=selector) book = models.ComicBook.objects.get(selector=selector)
return Response({'type': book.file_name.split('.')[-1].lower()}) return Response({'type': book.file_name.split('.')[-1].lower()})
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer) @swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer)
@@ -290,11 +270,15 @@ class ReadViewSet(viewsets.GenericViewSet):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
comic = get_object_or_404(models.ComicBook, selector=selector) comic_status, _ = models.ComicStatus.objects.annotate(page_count=F('comic__page_count')) \
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user) .get_or_create(comic_id=selector, user=request.user)
comic_status.last_read_page = serializer.data['page'] comic_status.last_read_page = serializer.data['page']
comic_status.unread = False comic_status.unread = False
comic_status.finished = comic.page_count > 0 and comic_status.last_read_page >= comic.page_count - 1 if comic_status.page_count - 1 == comic_status.last_read_page:
comic_status.finished = True
else:
comic_status.finished = False
comic_status.save() comic_status.save()
return Response(self.get_serializer(comic_status).data) return Response(self.get_serializer(comic_status).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -307,30 +291,20 @@ class PassthroughRenderer(renderers.BaseRenderer): # pylint: disable=too-few-pu
media_type = '*/*' media_type = '*/*'
format = '' format = ''
def render(self, data: bytes, accepted_media_type: str | None = None, renderer_context: str | None = None) \ def render(self, data: bytes, accepted_media_type: Optional[str] = None, renderer_context: Optional[str] = None) \
-> bytes: -> bytes:
if isinstance(data, (bytes, bytearray)): return data
return data
# Error responses still need to be serialised.
return json.dumps(data).encode()
class ImageViewSet(viewsets.ViewSet): class ImageViewSet(viewsets.ViewSet):
queryset = models.ComicBook.objects.all() queryset = models.ComicBook.objects.all()
lookup_field = 'page' lookup_field = 'page'
renderer_classes = [PassthroughRenderer, renderers.JSONRenderer] renderer_classes = [PassthroughRenderer]
permission_classes = [permissions.IsAuthenticated]
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response", @swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"})
status.HTTP_403_FORBIDDEN: 'User below classification allowed', def retrieve(self, _request: Request, parent_lookup_selector: UUID, page: int) -> FileResponse:
status.HTTP_404_NOT_FOUND: 'Comic or page not found'}) book = models.ComicBook.objects.get(selector=parent_lookup_selector)
def retrieve(self, _request: Request, selector: UUID, page: int) -> Response:
book = get_object_or_404(models.ComicBook, selector=selector)
if not user_can_read(_request.user, book.directory):
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
img, content = book.get_image(int(page) - 1) img, content = book.get_image(int(page) - 1)
if not img:
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Page could not be read.'})
self.renderer_classes[0].media_type = content self.renderer_classes[0].media_type = content
return FileResponse(img, content_type=content) return FileResponse(img, content_type=content)
@@ -458,7 +432,7 @@ class ActionViewSet(viewsets.GenericViewSet):
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user) obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
obj.unread = False obj.unread = False
obj.finished = True obj.finished = True
obj.last_read_page = max(comic.page_count - 1, 0) obj.last_read_page = comic.total_pages
status_to_update.append(obj) status_to_update.append(obj)
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page']) models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
return Response({'status': 'marked_read'}) return Response({'status': 'marked_read'})
@@ -490,7 +464,7 @@ class ActionViewSet(viewsets.GenericViewSet):
return Response(serializer.errors, return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
def get_comics(self, selectors: Iterable[str | UUID]) -> list[str]: def get_comics(self, selectors: Iterable[Union[str, UUID]]) -> List[str]:
data = set() data = set()
data = data.union( data = data.union(
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True))) set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
@@ -532,10 +506,10 @@ class PasswordResetSerializer(serializers.Serializer):
try: try:
validate_password(data) validate_password(data)
except ValidationError as err: except ValidationError as err:
raise serializers.ValidationError(err) from err raise serializers.ValidationError(err)
return data return data
def validate(self, attrs: dict[str, str]) -> dict[str, str]: def validate(self, attrs: Dict[str, str]) -> Dict[str, str]:
super().validate(attrs) super().validate(attrs)
if attrs['new_password'] != attrs['new_password_confirm']: if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError('New passwords do not match') raise serializers.ValidationError('New passwords do not match')
@@ -606,47 +580,37 @@ class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
permission_classes = [permissions.IsAdminUser] permission_classes = [permissions.IsAdminUser]
lookup_field = 'selector' lookup_field = 'selector'
@staticmethod
def _descendants(directory: models.Directory) -> list[models.Directory]:
"""The directory itself plus every directory below it."""
to_update = [directory]
to_visit = [directory]
while to_visit:
parent = to_visit.pop()
for child in models.Directory.objects.filter(parent=parent):
to_visit.append(child)
to_update.append(child)
return to_update
@swagger_auto_schema(responses={200: DirectorySerializer(many=True)}) @swagger_auto_schema(responses={200: DirectorySerializer(many=True)})
def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ
""" """
This will set the classification of a directory and all of its children. This will set the classification of a directory and all it's children.
""" """
main_parent = get_object_or_404(models.Directory, selector=selector) main_parent = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
to_update = self._descendants(main_parent) if serializer.is_valid():
for directory in to_update: main_parent.classification = serializer.data['classification']
directory.classification = serializer.data['classification'] to_update = {main_parent}
models.Directory.objects.bulk_update(to_update, fields=['classification']) to_visit = {main_parent}
return Response(self.get_serializer(to_update, many=True).data)
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)
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(responses={200: DirectorySerializer()})
def partial_update(self, request: Request, *args, **kwargs) -> Response: def partial_update(self, request: Request, *args, **kwargs) -> Response:
""" """
This will set the classification of a directory and none of its children. This will set the classification of a directory and none of its children.
""" """
directory = self.get_object() return super().update(request, *args, **kwargs)
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
directory.classification = serializer.data['classification']
directory.save()
return Response(self.get_serializer(directory).data)
class InitialSetupSerializer(serializers.Serializer): class InitialSetupSerializer(serializers.Serializer):
-18
View File
@@ -1,18 +0,0 @@
"""The folder scan as a task.
The default backend runs tasks immediately, so this behaves like a function call today.
Writing it as a task is what lets the scan move to a worker later without changing any
of the call sites in comic/rest.py or the management command.
"""
from django.tasks import task
from comic import models
from comic.processing import reconcile
@task
def scan_directory(selector: str | None = None) -> None:
"""Reconcile one directory, or the root of the comic volume when no selector is given."""
directory = models.Directory.objects.filter(selector=selector).first() if selector else None
reconcile(directory)
-48
View File
@@ -1,48 +0,0 @@
"""Shared fixtures for the test suite."""
from pathlib import Path
import pytest
from comic import models
from comic.tests.factories import make_cbz
@pytest.fixture()
def comic_volume(settings, tmp_path) -> Path:
"""Point COMIC_BOOK_VOLUME at an empty temporary directory."""
settings.COMIC_BOOK_VOLUME = tmp_path
return tmp_path
@pytest.fixture()
def admin_user(db) -> models.User:
return models.User.objects.create_superuser('admin', 'admin@example.com', 'password12345')
@pytest.fixture()
def reader_user(db) -> models.User:
"""A normal user, whose reading allowance defaults to the highest rating."""
return models.User.objects.create_user('reader', 'reader@example.com', 'password12345')
@pytest.fixture()
def restricted_user(db) -> models.User:
"""A user whose allowance is G, so anything rated above G is blocked."""
user = models.User.objects.create_user('restricted', 'restricted@example.com', 'password12345')
models.UserMisc.objects.create(user=user, allowed_to_read=models.Directory.Classification.C_G)
return user
@pytest.fixture()
def rated_directory(db) -> models.Directory:
return models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
@pytest.fixture()
def book(db, comic_volume: Path, rated_directory: models.Directory) -> models.ComicBook:
"""A book with a three page archive inside an 18 rated directory."""
directory_path = comic_volume / rated_directory.name
directory_path.mkdir(parents=True, exist_ok=True)
make_cbz(directory_path / 'issue.cbz', pages=3)
return models.ComicBook.objects.create(file_name='issue.cbz', directory=rated_directory, page_count=3)
-31
View File
@@ -1,31 +0,0 @@
"""Helpers for creating comic files on disk."""
import io
import zipfile
from pathlib import Path
import pymupdf
from PIL import Image
def make_cbz(path: Path, pages: int = 3, metadata: bool = True) -> Path:
"""Write a .cbz containing PNG pages and, optionally, a ComicInfo.xml."""
with zipfile.ZipFile(path, 'w') as archive:
for page in range(pages):
image = Image.new('RGB', (10, 10))
buffer = io.BytesIO()
image.save(buffer, format='PNG')
archive.writestr(f'page{page:02}.png', buffer.getvalue())
if metadata:
archive.writestr('ComicInfo.xml', '<ComicInfo><Title>Test</Title></ComicInfo>')
return path
def make_pdf(path: Path, pages: int = 2) -> Path:
"""Write a PDF with the given number of pages."""
document = pymupdf.open()
for _ in range(pages):
document.new_page(width=10, height=10)
document.save(path)
document.close()
return path
-203
View File
@@ -1,203 +0,0 @@
"""Tests for the API behaviour of the comic viewsets."""
import json
import pytest
from comic import models
from comic.tests.factories import make_cbz
UNKNOWN = '00000000-0000-0000-0000-000000000000'
@pytest.mark.django_db
def test_unknown_directory_returns_404(client, reader_user) -> None:
client.force_login(reader_user)
assert client.get(f'/api/browse/{UNKNOWN}/').status_code == 404
@pytest.mark.django_db
def test_unknown_selector_returns_404_in_breadcrumbs(client, reader_user) -> None:
client.force_login(reader_user)
assert client.get(f'/api/browse/{UNKNOWN}/breadcrumbs/').status_code == 404
@pytest.mark.django_db
def test_unknown_book_returns_404_for_an_image(client, reader_user) -> None:
client.force_login(reader_user)
assert client.get(f'/api/read/{UNKNOWN}/image/1/').status_code == 404
@pytest.mark.django_db
def test_unknown_book_returns_404_for_the_type_action(client, reader_user) -> None:
client.force_login(reader_user)
assert client.get(f'/api/read/{UNKNOWN}/type/').status_code == 404
@pytest.mark.django_db
def test_image_requires_authentication(client, book) -> None:
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 401
@pytest.mark.django_db
def test_image_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
client.force_login(restricted_user)
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 403
@pytest.mark.django_db
def test_image_returns_the_page_for_an_allowed_user(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.get(f'/api/read/{book.selector}/image/1/')
assert response.status_code == 200
assert response['Content-Type'] == 'image/png'
assert b''.join(response.streaming_content)
@pytest.mark.django_db
def test_read_view_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
client.force_login(restricted_user)
assert client.get(f'/api/read/{book.selector}/').status_code == 403
@pytest.mark.django_db
def test_read_view_returns_progress_for_an_allowed_user(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.get(f'/api/read/{book.selector}/')
assert response.status_code == 200
assert response.json()['pages'] == 3
assert response.json()['title'] == 'issue.cbz'
@pytest.mark.django_db
def test_set_page_creates_the_status_and_marks_the_book_finished(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.put(
f'/api/read/{book.selector}/set_page/',
data={'page': book.page_count - 1},
content_type='application/json',
)
assert response.status_code == 200
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
assert status.last_read_page == 2
assert status.finished
assert not status.unread
@pytest.mark.django_db
def test_set_page_leaves_the_book_unfinished_before_the_last_page(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.put(
f'/api/read/{book.selector}/set_page/',
data={'page': 1},
content_type='application/json',
)
assert response.status_code == 200
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
assert not status.finished
@pytest.mark.django_db
def test_mark_read_records_the_last_page(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.put(
'/api/action/mark_read/',
data={'selectors': [str(book.selector)]},
content_type='application/json',
)
assert response.status_code == 200
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
assert status.last_read_page == book.page_count - 1
assert status.finished
@pytest.mark.django_db
def test_directory_update_cascades_to_children(client, admin_user) -> None:
client.force_login(admin_user)
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
child = models.Directory.objects.create(name='Child', parent=parent)
response = client.put(
f'/api/directory/{parent.selector}/',
data={'classification': models.Directory.Classification.C_PG},
content_type='application/json',
)
assert response.status_code == 200
assert sorted(item['classification'] for item in response.json()) == [1, 1]
child.refresh_from_db()
assert child.classification == models.Directory.Classification.C_PG
@pytest.mark.django_db
def test_directory_partial_update_does_not_cascade(client, admin_user) -> None:
client.force_login(admin_user)
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
child = models.Directory.objects.create(name='Child', parent=parent)
response = client.patch(
f'/api/directory/{child.selector}/',
data={'classification': models.Directory.Classification.C_15},
content_type='application/json',
)
assert response.status_code == 200
assert response.json() == {'selector': str(child.selector), 'classification': 3}
parent.refresh_from_db()
assert parent.classification == models.Directory.Classification.C_18
@pytest.mark.django_db
def test_directory_partial_update_rejects_an_unknown_classification(client, admin_user) -> None:
client.force_login(admin_user)
directory = models.Directory.objects.create(name='Parent')
response = client.patch(
f'/api/directory/{directory.selector}/',
data={'classification': 9},
content_type='application/json',
)
assert response.status_code == 400
@pytest.mark.django_db
def test_browse_listing_does_not_write_to_the_database(client, reader_user, comic_volume) -> None:
"""Listing is read only, so a browse request cannot race with the scan."""
client.force_login(reader_user)
target = comic_volume / 'Series'
target.mkdir()
make_cbz(target / 'issue.cbz', pages=2)
assert client.get('/api/browse/').status_code == 200
assert models.ComicBook.objects.count() == 0
assert models.ComicStatus.objects.count() == 0
@pytest.mark.django_db
def test_scan_endpoint_indexes_files_that_are_only_on_disk(client, reader_user, comic_volume) -> None:
client.force_login(reader_user)
target = comic_volume / 'Series'
target.mkdir()
make_cbz(target / 'issue.cbz', pages=2)
assert [entry['title'] for entry in client.get('/api/browse/').json()] == []
response = client.post('/api/browse/scan/', data='{}', content_type='application/json')
assert response.status_code == 200
assert [entry['title'] for entry in response.json()] == ['Series']
directory = models.Directory.objects.get(name='Series')
response = client.post('/api/browse/scan/',
data=json.dumps({'selector': str(directory.selector)}),
content_type='application/json')
assert response.status_code == 200
entries = response.json()
assert [entry['title'] for entry in entries] == ['issue.cbz']
assert entries[0]['type'] == 'ComicBook'
assert entries[0]['total'] == 2
assert entries[0]['progress'] == 0
assert entries[0]['unread'] is True
@pytest.mark.django_db
def test_scan_endpoint_requires_authentication(client) -> None:
assert client.post('/api/browse/scan/', data='{}', content_type='application/json').status_code == 401
-42
View File
@@ -1,42 +0,0 @@
"""Tests for the shared archive page listing in comic.archives."""
import zipfile
from pathlib import Path
from comic.archives import get_archive_files
from comic.tests.factories import make_cbz
def names(archive: zipfile.ZipFile) -> list[str]:
return [entry.file_name for entry in get_archive_files(archive)]
def test_metadata_entries_are_not_pages(tmp_path: Path) -> None:
path = make_cbz(tmp_path / 'issue.cbz', pages=2, metadata=True)
with zipfile.ZipFile(path) as archive:
assert names(archive) == ['page00.png', 'page01.png']
def test_entries_are_returned_in_page_order(tmp_path: Path) -> None:
path = tmp_path / 'issue.cbz'
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr('page10.png', b'x')
archive.writestr('page02.png', b'x')
archive.writestr('page01.png', b'x')
with zipfile.ZipFile(path) as archive:
assert names(archive) == ['page01.png', 'page02.png', 'page10.png']
def test_directory_entries_are_ignored(tmp_path: Path) -> None:
path = tmp_path / 'issue.cbz'
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr('cover.png', b'x')
archive.writestr('images/', b'')
with zipfile.ZipFile(path) as archive:
assert names(archive) == ['cover.png']
def test_mime_type_is_guessed_from_the_file_name(tmp_path: Path) -> None:
path = make_cbz(tmp_path / 'issue.cbz', pages=1, metadata=False)
with zipfile.ZipFile(path) as archive:
assert get_archive_files(archive)[0].mime_type == 'image/png'
-87
View File
@@ -1,87 +0,0 @@
"""Tests for cookie based JWT authentication."""
import json
import pytest
from django.test import Client
from comic import auth
CREDENTIALS = {'username': 'reader', 'password': 'password12345'}
def _store_cookies(client: Client, response) -> None:
"""Copy Set-Cookie headers into the test client, which does not do it itself."""
for name, morsel in response.cookies.items():
if morsel.value:
client.cookies[name] = morsel.value
elif name in client.cookies:
del client.cookies[name]
@pytest.mark.django_db
def test_login_sets_http_only_cookies(client: Client, reader_user) -> None:
response = client.post('/api/token/', CREDENTIALS)
assert response.status_code == 200
# The tokens are not returned to JavaScript, only stored as cookies.
assert json.loads(response.content) == {}
for name in (auth.access_cookie_name(), auth.refresh_cookie_name()):
assert response.cookies[name].value
assert response.cookies[name]['httponly']
@pytest.mark.django_db
def test_a_cookie_authenticates_a_request(client: Client, reader_user) -> None:
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
response = client.get('/api/account/')
assert response.status_code == 200
assert json.loads(response.content)['username'] == reader_user.username
@pytest.mark.django_db
def test_anonymous_request_without_a_cookie_is_rejected(client: Client) -> None:
assert client.get('/api/account/').status_code == 401
@pytest.mark.django_db
def test_the_authorization_header_still_works(client: Client, reader_user) -> None:
response = client.post('/api/token/', CREDENTIALS)
token = response.cookies[auth.access_cookie_name()].value
response = client.get('/api/account/', headers={'Authorization': f'Bearer {token}'})
assert response.status_code == 200
@pytest.mark.django_db
def test_refresh_rotates_the_access_cookie(client: Client, reader_user) -> None:
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
old_access = client.cookies[auth.access_cookie_name()].value
old_refresh = client.cookies[auth.refresh_cookie_name()].value
response = client.post('/api/token/refresh/', {})
assert response.status_code == 200
_store_cookies(client, response)
assert client.cookies[auth.access_cookie_name()].value != old_access
assert client.cookies[auth.refresh_cookie_name()].value != old_refresh
# The rotated refresh token is blacklisted, so the original one no longer works.
assert client.post('/api/token/refresh/', {'refresh': old_refresh}).status_code == 401
@pytest.mark.django_db
def test_blacklisting_clears_the_cookies(client: Client, reader_user) -> None:
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
response = client.post('/api/token/blacklist/', {})
assert response.status_code == 204
_store_cookies(client, response)
for name in (auth.access_cookie_name(), auth.refresh_cookie_name()):
assert name not in client.cookies
assert client.get('/api/account/').status_code == 401
@pytest.mark.django_db
def test_page_images_can_be_loaded_with_a_cookie(client: Client, book, reader_user) -> None:
"""Browser initiated requests cannot send a header, so they rely on the cookie."""
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 200
-82
View File
@@ -1,82 +0,0 @@
"""Tests for the comic models."""
from pathlib import Path
import pytest
from django.db import IntegrityError
from comic import models
from comic.tests.factories import make_cbz, make_pdf
@pytest.mark.django_db
def test_page_count_excludes_metadata(comic_volume: Path) -> None:
make_cbz(comic_volume / 'issue.cbz', pages=3, metadata=True)
book = models.ComicBook.objects.create(file_name='issue.cbz')
assert book.get_page_count() == 3
@pytest.mark.django_db
def test_pdf_page_count(comic_volume: Path) -> None:
make_pdf(comic_volume / 'issue.pdf', pages=2)
book = models.ComicBook.objects.create(file_name='issue.pdf')
assert book.get_page_count() == 2
@pytest.mark.django_db
def test_unreadable_archive_is_indexed_without_pages(comic_volume: Path) -> None:
path = comic_volume / 'broken.cbz'
path.write_bytes(b'this is not an archive')
result = models.ComicBook.process_comic_book(path)
assert isinstance(result, Path)
assert models.ComicBook.objects.get(file_name='broken.cbz').page_count == 0
@pytest.mark.django_db
def test_archive_path_uses_the_directory_tree(comic_volume: Path) -> None:
parent = models.Directory.objects.create(name='Series')
child = models.Directory.objects.create(name='Volume 1', parent=parent)
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
assert book.get_archive_path == comic_volume / 'Series' / 'Volume 1' / 'issue.cbz'
@pytest.mark.django_db
def test_book_without_a_directory_uses_the_volume_root(comic_volume: Path) -> None:
book = models.ComicBook.objects.create(file_name='issue.cbz')
assert book.get_archive_path == comic_volume / 'issue.cbz'
@pytest.mark.django_db
def test_directory_path_is_built_from_its_parents() -> None:
parent = models.Directory.objects.create(name='Series')
child = models.Directory.objects.create(name='Volume 1', parent=parent)
assert child.get_path() == Path('Series') / 'Volume 1'
@pytest.mark.django_db
def test_get_image_returns_the_requested_page(comic_volume: Path) -> None:
make_cbz(comic_volume / 'issue.cbz', pages=2, metadata=False)
book = models.ComicBook.objects.create(file_name='issue.cbz')
stream, mime_type = book.get_image(1)
assert mime_type == 'image/png'
assert stream.read()
@pytest.mark.django_db
def test_classification_comparison() -> None:
directory = models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
misc = models.UserMisc.objects.create(
user=models.User.objects.create_user('pg', 'pg@example.com', 'password12345'),
allowed_to_read=models.Directory.Classification.C_PG,
)
assert directory.classification > misc.allowed_to_read
@pytest.mark.django_db
def test_duplicate_directory_names_are_rejected() -> None:
"""The scan depends on this constraint: without it two concurrent requests can create
the same directory twice and the browse listing shows it twice."""
parent = models.Directory.objects.create(name='Series')
models.Directory.objects.create(name='Volume 1', parent=parent)
with pytest.raises(IntegrityError):
models.Directory.objects.create(name='Volume 1', parent=parent)
-135
View File
@@ -1,135 +0,0 @@
"""Tests for the filesystem reconciliation in comic.processing."""
import pytest
from comic import models
from comic.processing import clean_directories, clean_files, list_directory, reconcile
from comic.tests.factories import make_cbz
@pytest.mark.django_db
def test_clean_directories_adds_new_and_removes_stale(comic_volume, reader_user) -> None:
parent = models.Directory.objects.create(name='Series')
models.Directory.objects.create(name='Gone', parent=parent)
(comic_volume / 'Series' / 'New').mkdir(parents=True)
clean_directories(models.Directory.objects.filter(parent=parent), comic_volume / 'Series', parent)
names = set(models.Directory.objects.filter(parent=parent).values_list('name', flat=True))
assert names == {'New'}
@pytest.mark.django_db
def test_clean_files_ignores_unsupported_extensions(comic_volume, reader_user) -> None:
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=2)
(target / 'notes.txt').write_text('not a comic')
clean_files(models.ComicBook.objects.filter(directory=directory), target, directory)
books = list(models.ComicBook.objects.filter(directory=directory))
assert [book.file_name for book in books] == ['issue.cbz']
assert books[0].page_count == 2
@pytest.mark.django_db
def test_reconcile_indexes_the_directory_without_touching_the_database_on_a_second_run(
comic_volume, reader_user) -> None:
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=2)
reconcile(directory)
first = list(models.ComicBook.objects.filter(directory=directory))
assert [book.file_name for book in first] == ['issue.cbz']
reconcile(directory)
assert list(models.ComicBook.objects.filter(directory=directory)) == first
@pytest.mark.django_db
def test_list_directory_reports_an_unread_book_without_creating_a_status(comic_volume,
reader_user) -> None:
"""Listing is read only: a book nobody has opened has no ComicStatus row at all."""
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=3)
reconcile(directory)
entries = list_directory(reader_user, directory)
assert [entry.title for entry in entries] == ['issue.cbz']
assert entries[0].progress == 0
assert entries[0].unread is True
assert entries[0].finished is False
assert models.ComicStatus.objects.count() == 0
@pytest.mark.django_db
def test_list_directory_shows_progress_for_a_book_that_has_been_read(comic_volume,
reader_user) -> None:
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=3)
reconcile(directory)
book = models.ComicBook.objects.get(directory=directory)
models.ComicStatus.objects.create(user=reader_user, comic=book, last_read_page=1, unread=False)
entries = list_directory(reader_user, directory)
assert entries[0].progress == 2
assert entries[0].unread is False
assert entries[0].finished is False
@pytest.mark.django_db
def test_clean_files_survives_a_concurrent_scan_of_the_same_directory(comic_volume, reader_user,
rated_directory) -> None:
"""Two requests can build the same 'new file' list; the second insert must not clash."""
target = comic_volume / rated_directory.name
target.mkdir(parents=True, exist_ok=True)
make_cbz(target / 'issue.cbz', pages=2)
stale = models.ComicBook.objects.filter(directory=rated_directory)
list(stale) # force evaluation so the cached snapshot is empty
models.ComicBook.objects.create(file_name='issue.cbz', directory=rated_directory, page_count=2)
clean_files(stale, target, rated_directory)
assert models.ComicBook.objects.filter(directory=rated_directory).count() == 1
@pytest.mark.django_db
def test_clean_files_ignores_rows_another_request_already_removed(comic_volume, reader_user,
rated_directory) -> None:
target = comic_volume / rated_directory.name
target.mkdir(parents=True, exist_ok=True)
stale_book = models.ComicBook.objects.create(file_name='gone.cbz', directory=rated_directory)
stale = models.ComicBook.objects.filter(directory=rated_directory)
list(stale) # cache the row, then let another request delete it
stale_book.delete()
clean_files(stale, target, rated_directory)
assert models.ComicBook.objects.filter(directory=rated_directory).count() == 0
@pytest.mark.django_db
def test_clean_directories_does_not_duplicate_rows_when_another_request_inserts_first(comic_volume,
reader_user) -> None:
parent = models.Directory.objects.create(name='Series')
(comic_volume / 'Series' / 'New').mkdir(parents=True)
stale = models.Directory.objects.filter(parent=parent)
list(stale) # cached snapshot is empty
models.Directory.objects.create(name='New', parent=parent)
clean_directories(stale, comic_volume / 'Series', parent)
assert models.Directory.objects.filter(parent=parent).count() == 1
-56
View File
@@ -1,56 +0,0 @@
"""Tests for the scan_comics management command."""
from pathlib import Path
import pytest
from django.core.management import call_command
from comic import models
from comic.tests.factories import make_cbz
@pytest.mark.django_db
def test_scan_indexes_nested_directories(comic_volume: Path, reader_user) -> None:
nested = comic_volume / 'Series' / 'Volume 1'
nested.mkdir(parents=True)
make_cbz(nested / 'issue.cbz', pages=2)
call_command('scan_comics')
directory = models.Directory.objects.get(name='Volume 1', parent__name='Series')
book = models.ComicBook.objects.get(file_name='issue.cbz', directory=directory)
assert book.page_count == 2
@pytest.mark.django_db
def test_scan_if_empty_scans_an_empty_database(comic_volume: Path) -> None:
make_cbz(comic_volume / 'issue.cbz', pages=1)
call_command('scan_comics', if_empty=True)
assert models.ComicBook.objects.count() == 1
@pytest.mark.django_db
def test_scan_if_empty_skips_when_books_already_exist(comic_volume: Path) -> None:
"""Files added while the container was down are left to the cron scan."""
make_cbz(comic_volume / 'issue.cbz', pages=1)
call_command('scan_comics')
make_cbz(comic_volume / 'later.cbz', pages=1)
call_command('scan_comics', if_empty=True)
assert models.ComicBook.objects.count() == 1
call_command('scan_comics')
assert models.ComicBook.objects.count() == 2
@pytest.mark.django_db
def test_scan_runs_without_any_user(comic_volume: Path) -> None:
"""The command no longer needs a user, because statuses are created when a book is read."""
make_cbz(comic_volume / 'issue.cbz', pages=1)
call_command('scan_comics')
assert models.ComicBook.objects.count() == 1
assert models.ComicStatus.objects.count() == 0
-17
View File
@@ -1,17 +0,0 @@
"""Tests for the scan task."""
import pytest
from comic import models, tasks
from comic.tests.factories import make_cbz
@pytest.mark.django_db
def test_scan_task_runs_through_the_task_backend(comic_volume) -> None:
"""The immediate backend executes the task in the caller, so this is the whole path."""
make_cbz(comic_volume / 'issue.cbz', pages=2)
tasks.scan_directory.enqueue()
assert models.ComicBook.objects.count() == 1
assert models.ComicBook.objects.get().page_count == 2
-14
View File
@@ -1,14 +0,0 @@
"""Tests for URL resolution that depends on settings."""
import pytest
from django.test import Client
@pytest.mark.parametrize('url', ['/swagger.json', '/swagger.yaml', '/swagger/', '/redoc/'])
def test_schema_urls_resolve(client: Client, url: str) -> None:
"""The schema URLs must match the renderer formats, which no longer include a dot.
cbreader/urls.py captures the format after the dot because
SWAGGER_USE_COMPAT_RENDERERS is False.
"""
assert client.get(url).status_code == 200
-22
View File
@@ -1,22 +0,0 @@
"""Tests for breadcrumb generation."""
import pytest
from comic import models
from comic.util import generate_breadcrumbs_from_path
@pytest.mark.django_db
def test_breadcrumbs_run_from_home_to_the_book() -> None:
parent = models.Directory.objects.create(name='Series')
child = models.Directory.objects.create(name='Volume 1', parent=parent)
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
assert [crumb.name for crumb in generate_breadcrumbs_from_path(child, book)] == [
'Home', 'Series', 'Volume 1', 'issue.cbz',
]
@pytest.mark.django_db
def test_breadcrumbs_without_a_book_stop_at_the_directory() -> None:
directory = models.Directory.objects.create(name='Series')
assert [crumb.name for crumb in generate_breadcrumbs_from_path(directory)] == ['Home', 'Series']
View File
Whitespace-only changes.
+6 -5
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from comic.models import ComicBook, Directory from comic.models import ComicBook, Directory
@@ -9,12 +10,12 @@ class Breadcrumb:
selector: str = '' selector: str = ''
def generate_breadcrumbs_from_path( def generate_breadcrumbs_from_path(directory: Optional[Directory] = None, book: Optional[ComicBook] = None):
directory: Directory | None = None,
book: ComicBook | None = None,
) -> list[Breadcrumb]:
output = [Breadcrumb()] output = [Breadcrumb()]
folders = directory.get_path_objects() if directory else [] if directory:
folders = directory.get_path_objects()
else:
folders = []
for item in folders[::-1]: for item in folders[::-1]:
output.append( output.append(
Breadcrumb( Breadcrumb(
View File
Whitespace-only changes.
+20 -28
View File
@@ -1,72 +1,64 @@
version: "2.4"
services: services:
cbwebreader: cbwebreader:
build: ../
image: ajurna/cbwebreader image: ajurna/cbwebreader
env_file: .env env_file: .env
links:
- database
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
expose: expose:
- 8000 - 8000
volumes: volumes:
# COMIC_BOOK_VOLUME is the path *inside* the container; the host path is - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
# a separate variable so a Windows host path is not passed to Django.
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- .env:/src/.env - .env:/src/.env
command: sh entrypoint.sh command: /bin/bash entrypoint.sh
restart: unless-stopped
cbwebreader-cron: cbwebreader-cron:
build: ../
image: ajurna/cbwebreader image: ajurna/cbwebreader
env_file: .env env_file: .env
links:
- database
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME} - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
- media_files:/media - media_files:/media
- .env:/src/.env - .env:/src/.env
# The image HEALTHCHECK probes the web port, which this service never serves. command: /bin/bash entrypoint-cron.sh
healthcheck:
disable: true
command: sh entrypoint-cron.sh
restart: unless-stopped
database: database:
image: postgres:16-alpine image: postgres:11.4-alpine
expose: expose:
- 5432 - 5432
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - ./data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"]
interval: 5s interval: 5s
timeout: 10s timeout: 10s
retries: 3 retries: 3
environment: environment:
POSTGRES_USER: ${DB_USER} - POSTGRES_USER=${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS} - POSTGRES_PASSWORD=${DB_PASS}
POSTGRES_DB: ${DB_DATABASE} - POSTGRES_DB=${DB_DATABASE}
restart: unless-stopped
nginx: nginx:
image: nginx:1-alpine image: nginx
volumes: volumes:
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- ./nginx.conf:/etc/nginx/conf.d/default.conf - ./nginx.conf:/etc/nginx/conf.d/default.conf
ports: ports:
- "${WEB_PORT:-8337}:80" - 1337:80
depends_on: depends_on:
cbwebreader: - cbwebreader
condition: service_healthy
restart: unless-stopped
volumes: volumes:
static_files: static_files:
media_files: media_files:
postgres_data:
+1 -8
View File
@@ -6,13 +6,6 @@ server {
listen 80; listen 80;
# nginx compresses only text/html by default, so the bundle and CSS need explicit types.
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/wasm;
gzip_vary on;
location / { location / {
proxy_pass http://cbwebreader_django; proxy_pass http://cbwebreader_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -27,4 +20,4 @@ server {
alias /media/; alias /media/;
} }
} }
+29 -36
View File
@@ -1,72 +1,65 @@
version: "2.4"
services: services:
cbwebreader: cbwebreader:
build: . build: .
image: ajurna/cbwebreader
env_file: .env env_file: .env
links:
- database
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
expose: expose:
- 8000 - 8000
volumes: volumes:
# COMIC_BOOK_VOLUME is the path *inside* the container; the host path is - ${COMIC_BOOK_VOLUME}:/comics
# a separate variable so a Windows host path is not passed to Django. # - c:/comics:/comics
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- .env:/src/.env - .env:/src/.env
command: sh entrypoint.sh command: /bin/bash /src/entrypoint.sh
restart: unless-stopped
cbwebreader-cron: # cbwebreader-cron:
build: . # build: .
image: ajurna/cbwebreader # env_file: .env
env_file: .env # links:
depends_on: # - database
database: # depends_on:
condition: service_healthy # database:
volumes: # condition: service_healthy
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME} # volumes:
- media_files:/media # - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
- .env:/src/.env # - media_files:/media
# The image HEALTHCHECK probes the web port, which this service never serves. # - .env:/src/.env
healthcheck: # command: /bin/bash entrypoint-cron.sh
disable: true
command: sh entrypoint-cron.sh
restart: unless-stopped
database: database:
image: postgres:16-alpine image: postgres:16-alpine
expose: expose:
- 5432 - 5432
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - /var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"]
interval: 5s interval: 5s
timeout: 10s timeout: 10s
retries: 3 retries: 3
environment: environment:
POSTGRES_USER: ${DB_USER} - POSTGRES_USER=${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS} - POSTGRES_PASSWORD=${DB_PASS}
POSTGRES_DB: ${DB_DATABASE} - POSTGRES_DB=${DB_DATABASE}
restart: unless-stopped
nginx: nginx:
image: nginx:1-alpine image: nginx
volumes: volumes:
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- ./compose/nginx.conf:/etc/nginx/conf.d/default.conf - ./cbreader/settings/nginx.conf:/etc/nginx/conf.d/default.conf
ports: ports:
- "${WEB_PORT:-8337}:80" - 8337:80
depends_on: depends_on:
cbwebreader: - cbwebreader
condition: service_healthy
restart: unless-stopped
volumes: volumes:
static_files: static_files:
media_files: media_files:
postgres_data:
+2 -9
View File
@@ -1,12 +1,5 @@
#!/usr/bin/env sh
set -e
python manage.py migrate --settings=cbreader.settings.base python manage.py migrate --settings=cbreader.settings.base
# Index the library on the first run so the UI is not empty until cron gets to it. python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
# --if-empty makes this a no-op on every later start.
python manage.py scan_comics --if-empty --settings=cbreader.settings.base
python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
exec gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
-61
View File
@@ -1,61 +0,0 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
export default [
{ ignores: ['dist/**', 'node_modules/**', 'public/**'] },
js.configs.recommended,
...pluginVue.configs['flat/essential'],
// Build scripts (webpack.*.js) run in Node, so they use CommonJS globals.
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'readonly',
__dirname: 'readonly',
process: 'readonly',
console: 'readonly',
},
},
},
// Tests run under Vitest with a jsdom environment.
{
files: ['test/**'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
document: 'readonly',
localStorage: 'readonly',
console: 'readonly',
window: 'readonly',
HTMLElement: 'readonly',
KeyboardEvent: 'readonly',
},
},
},
// Application code is browser code bundled by webpack.
{
files: ['src/**'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
IntersectionObserver: 'readonly',
process: 'readonly',
},
},
rules: {},
},
]
+9909 -5527
View File
File diff suppressed because it is too large. Load diff
+46 -30
View File
@@ -1,46 +1,62 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "webpack-dev-server --config webpack.dev.js", "serve": "webpack-dev-server --config webpack.dev.js",
"build": "webpack --config webpack.prod.js", "build": "webpack --config webpack.prod.js",
"lint": "eslint .", "lint": "vue-cli-service lint"
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/vue-fontawesome": "^3.3.3", "@fortawesome/vue-fontawesome": "^3.0.1",
"axios": "^1.20.0", "axios": "^0.27.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.2.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"jwt-decode": "^4.0.0", "jwt-decode": "^3.1.2",
"reveal.js": "^6.0.2", "pdfvuer": "^2.0.1",
"reveal.js": "^4.3.1",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"vue": "^3.5.44", "vue": "^3.2.26",
"vue-router": "^5.4.0", "vue-router": "^4.0.3",
"vue-toast-notification": "^3.1.3", "vue-toast-notification": "3.0",
"vuejs-paginate-next": "^1.0.2", "vuejs-paginate-next": "^1.0.2",
"vuex": "^4.1.0" "vuex": "^4.0.0",
"webpack": "^5.76.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@babel/core": "^7.12.16",
"@vitejs/plugin-vue": "^6.0.10", "@babel/eslint-parser": "^7.12.16",
"@vue/test-utils": "^2.5.1", "@vue/cli-plugin-babel": "~5.0.0",
"css-loader": "^7.1.5", "@vue/cli-plugin-eslint": "~5.0.0",
"eslint": "^10.12.0", "@vue/cli-plugin-router": "~5.0.0",
"eslint-plugin-vue": "^10.11.1", "@vue/cli-plugin-vuex": "~5.0.0",
"jsdom": "^30.1.2", "@vue/cli-service": "~5.0.0",
"mini-css-extract-plugin": "^2.10.2", "eslint": "^7.32.0",
"style-loader": "^4.0.0", "eslint-plugin-vue": "^8.0.3",
"terser-webpack-plugin": "^5.6.1", "jshint": "^2.13.5",
"vitest": "^5.0.4", "mini-css-extract-plugin": "^2.6.1",
"vue-loader": "^17.4.2", "style-loader": "^3.3.1",
"webpack": "^5.111.1", "terser-webpack-plugin": "^5.3.6",
"webpack-bundle-tracker": "^3.2.4", "vue-loader": "^17.0.0",
"webpack-cli": "^7.2.3", "webpack-bundle-analyzer": "^4.6.1",
"webpack-dev-server": "^6.0.0" "webpack-bundle-tracker": "^1.6.0",
"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": [ "browserslist": [
"defaults" "defaults"
+28 -41
View File
@@ -1,53 +1,40 @@
import axios from "axios"; import axios from "axios";
import router from "@/router"; import router from "@/router";
import store from "@/store"; import store from "@/store";
import jwtDecode from "jwt-decode";
// Authentication is cookie based: the browser sends the HttpOnly access token async function get_access_token() {
// automatically, so there is no Authorization header to set here. let access = jwtDecode(store.state.jwt.access)
const axios_jwt = axios.create({ withCredentials: true }); let refresh = jwtDecode(store.state.jwt.refresh)
if (access.exp - Date.now()/1000 < 5) {
// Add the CSRF header if a csrftoken cookie exists. Cookie authentication is if (refresh.exp - Date.now()/1000 < 5) {
// protected against cross-site form posts by SameSite=Lax, so this is only used await router.push({name: 'login'})
// when the backend does set the cookie. return null
axios_jwt.interceptors.request.use(function(config) { } else {
const csrfToken = document.cookie return store.dispatch('refreshToken').then(() => {return store.state.jwt.access})
.split('; ') }
.find(row => row.startsWith('csrftoken=')) }
?.split('=')[1]; return store.state.jwt.access
if (csrfToken) {
config.headers['X-CSRFToken'] = csrfToken;
} }
return config; const axios_jwt = axios.create();
});
// A 401 means the access cookie is no longer valid. Try to refresh it once; if axios_jwt.interceptors.request.use(async function (config) {
// that fails the session is gone, so log out and send the user to the login page. let access_token = await get_access_token().catch(() => {
axios_jwt.interceptors.response.use( if (router.currentRoute.value.fullPath.includes('login')){
(response) => response, router.push({name: 'login'})
async (error) => { }else {
const config = error.config; router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }})
if (error.response?.status === 401 && config && !config._retried && !config.url.includes('/token/')) {
config._retried = true;
try {
await store.dispatch('refreshToken');
return await axios_jwt.request(config);
} catch {
store.commit('logOut');
router.push({
name: 'login',
query: {
next: config.url,
error: 'Your session has expired. Please log in again.'
}
});
}
} }
return Promise.reject(error); })
config.headers = {
Authorization: "Bearer " + access_token
} }
); return config
}, function (error) {
// Do something with request error
return Promise.reject(error);
});
export default axios_jwt export default axios_jwt
+3 -4
View File
@@ -23,7 +23,9 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<h2>Reading History</h2> <caption>
<h2>Reading History</h2>
</caption>
</div> </div>
<div class="row"> <div class="row">
<table class="table table-striped table-bordered"> <table class="table table-striped table-bordered">
@@ -136,9 +138,6 @@ export default {
mounted() { mounted() {
this.updateComicList() this.updateComicList()
}, },
beforeUnmount() {
clearTimeout(this.timeout)
},
} }
</script> </script>
+1 -17
View File
@@ -11,9 +11,6 @@
<li><a class="dropdown-item" @click="markAll('mark_unread')"><font-awesome-icon icon='book' />Mark Un-read</a></li> <li><a class="dropdown-item" @click="markAll('mark_unread')"><font-awesome-icon icon='book' />Mark Un-read</a></li>
<li><a class="dropdown-item" @click="markAll('mark_read')"><font-awesome-icon icon='book-open' />Mark read</a></li> <li><a class="dropdown-item" @click="markAll('mark_read')"><font-awesome-icon icon='book-open' />Mark read</a></li>
</ul> </ul>
<button type="button" class="btn btn-outline-secondary" :disabled="scanning" title="Read this folder from disk again" @click="scanFolder">
<font-awesome-icon icon='refresh' />{{ scanning ? 'Scanning…' : 'Scan' }}
</button>
</div> </div>
</div> </div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto mt-1" > <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto mt-1" >
@@ -52,8 +49,7 @@ export default {
filter_read: false, filter_read: false,
filter_unread: false filter_unread: false
}, },
loading: true, loading: true
scanning: false
}}, }},
props: { props: {
selector: String selector: String
@@ -72,18 +68,6 @@ export default {
}) })
.catch((error) => {console.log(error)}) .catch((error) => {console.log(error)})
}, },
scanFolder () {
this.scanning = true
api.post('/api/browse/scan/', { selector: this.selector || null })
.then(response => {
this.comics = response.data
this.scanning = false
})
.catch((error) => {
console.log(error)
this.scanning = false
})
},
markPreviousRead (selector) { markPreviousRead (selector) {
let selectors = [] let selectors = []
this.comics.every((item) => { this.comics.every((item) => {
+67 -436
View File
@@ -1,74 +1,19 @@
<template> <template>
<div class="reveal" :class="{ continuous: mode === 'scroll' }" id="comic_box" ref="comic_box"> <div class="reveal" id="comic_box" ref="comic_box" >
<div id="slides_div" class="slides" ref="slides"> <div id="slides_div" class="slides" ref="slides">
<section <section class="" v-for="page in pages" :key="page" :data-menu-title="page" hidden>
v-for="page in visible_pages" <img :data-src="'/api/read/' + selector + '/image/' + page + '/'" class="w-100" :alt="page">
:key="page"
:data-page="page"
:data-menu-title="page"
:hidden="mode === 'paged'"
>
<img
:class="{ shown: mode === 'scroll' && inview.has(page) }"
:src="mode === 'scroll' ? image_url(page) : null"
:data-src="mode === 'paged' ? image_url(page) : null"
:loading="mode === 'scroll' ? 'lazy' : null"
class="w-100"
:alt="page"
>
</section> </section>
</div> </div>
<div class="sentinel" ref="sentinel" v-if="mode === 'scroll' && rendered < pages"></div>
</div> </div>
<div class="row navButtons pb-2"> <div class="row navButtons pb-2">
<div class="col-auto"> <comic-paginate
<div class="btn-group btn-group-sm" role="group" aria-label="Reading mode"> v-model="paginate_page"
<button :page_count="pages"
type="button" @setPage="this.setPage"
class="btn btn-outline-secondary" @prevComic="prevComic"
:class="{ active: mode === 'paged' }" @nextComic="nextComic"
@click="setMode('paged')" />
>One page</button>
<button
type="button"
class="btn btn-outline-secondary"
:class="{ active: mode === 'scroll' }"
@click="setMode('scroll')"
>Continuous</button>
</div>
</div>
<div class="col-auto">
<comic-paginate
v-model="paginate_page"
:page_count="pages"
@setPage="this.setPage"
@prevComic="prevComic"
@nextComic="nextComic"
/>
</div>
<div class="col-auto">
<button
type="button"
class="btn btn-sm btn-outline-secondary"
:class="{ active: show_shortcuts }"
:aria-pressed="show_shortcuts ? 'true' : 'false'"
@click="show_shortcuts = !show_shortcuts"
>?</button>
</div>
</div>
<div class="shortcuts" v-if="show_shortcuts" role="dialog" aria-label="Keyboard shortcuts">
<div class="shortcuts-head">
<strong>Keyboard shortcuts</strong>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="show_shortcuts = false">Close</button>
</div>
<table>
<tbody>
<tr v-for="shortcut in shortcuts" :key="shortcut.keys">
<td><kbd>{{ shortcut.keys }}</kbd></td>
<td>{{ shortcut.action }}</td>
</tr>
</tbody>
</table>
</div> </div>
</template> </template>
@@ -76,17 +21,9 @@
<script> <script>
import Reveal from "reveal.js"; import Reveal from "reveal.js";
import api from "@/api"; import api from "@/api";
import Hammer from 'hammerjs' import * as Hammer from 'hammerjs'
import ComicPaginate from "@/components/ComicPaginate"; import ComicPaginate from "@/components/ComicPaginate";
// How many pages are rendered at a time in continuous mode. The window grows as the
// reader approaches the end of what has already been rendered.
const WINDOW_SIZE = 20;
// Held keys repeat, so a key press is handled immediately and then ignored for this
// long. The page write is already debounced by queue_save, so nothing is sent per key.
const KEY_THROTTLE_MS = 100;
export default { export default {
name: "TheComicReader", name: "TheComicReader",
components: {ComicPaginate}, components: {ComicPaginate},
@@ -94,79 +31,23 @@ export default {
return { return {
current_page: 0, current_page: 0,
paginate_page: 1, paginate_page: 1,
inview: new Set(),
deck: null, deck: null,
title: '', title: '',
prev_comic: {}, prev_comic: {},
next_comic: {}, next_comic: {},
pages: 1, pages: 1,
rendered: 0,
visible: new Map(),
page_observer: null,
growth_observer: null,
save_timeout: null,
key_locked: false,
key_timeout: null,
show_shortcuts: false,
shortcuts: [
{ keys: '← / →', action: 'Previous / next page' },
{ keys: '↑ / ↓', action: 'Scroll within the page' },
{ keys: 'Space', action: 'Next page' },
{ keys: 'Shift + Space', action: 'Previous page' },
{ keys: 'n / p', action: 'Next / previous page' },
{ keys: 'Home / End', action: 'First / last page' },
{ keys: 'm', action: 'Switch reading mode' },
{ keys: '?', action: 'Show or hide this list' },
{ keys: 'Esc', action: 'Hide this list' },
],
} }
}, },
props: { props: {
selector: String selector: String
}, },
computed: {
mode () {
return this.$store.state.reader_mode
},
set_read_url () {
return '/api/read/' + this.selector + '/set_page/'
},
visible_pages () {
if (this.mode === 'paged') {
return Array.from({length: this.pages}, (_item, index) => index + 1)
}
return Array.from({length: Math.min(this.rendered, this.pages)}, (_item, index) => index + 1)
}
},
methods: { methods: {
image_url (page) { prevPage(){
return '/api/read/' + this.selector + '/image/' + page + '/'
},
prevPage(behavior = 'smooth'){
if (this.mode === 'scroll') {
if (this.current_page <= 0) {
this.prevComic()
} else {
this.current_page -= 1
this.scroll_to_page(this.current_page + 1, behavior)
}
return
}
// reveal initializes asynchronously, so a key pressed before the promise resolves
// has no deck to move.
if (!this.deck) {
return
}
if (this.deck.isFirstSlide()){ if (this.deck.isFirstSlide()){
this.prevComic() this.prevComic()
} else { } else {
this.current_page -= 1 this.current_page -= 1
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
// Pages can be taller than the viewport, so show the top of the new page instead
// of keeping the scroll offset left over from the previous one.
this.scroll_to_page(this.current_page + 1, behavior)
} }
}, },
prevComic(){ prevComic(){
@@ -181,179 +62,59 @@ export default {
params: {selector: this.next_comic.selector} params: {selector: this.next_comic.selector}
}) })
}, },
nextPage(behavior = 'smooth'){ nextPage(){
if (this.mode === 'scroll') {
if (this.current_page + 1 >= this.pages) {
this.nextComic()
} else {
this.current_page += 1
this.scroll_to_page(this.current_page + 1, behavior)
}
return
}
if (!this.deck) {
return
}
if (this.deck.isLastSlide()){ if (this.deck.isLastSlide()){
this.nextComic() this.nextComic()
} else { } else {
this.current_page += 1 this.current_page += 1
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
this.scroll_to_page(this.current_page + 1, behavior)
} }
}, },
setPage(pageNum){ setPage(pageNum){
if (this.mode === 'scroll') {
const page = Math.min(pageNum, this.pages)
this.rendered = Math.max(this.rendered, page)
this.current_page = page - 1
this.$nextTick(() => {
this.observe_pages()
this.scroll_to_page(page)
})
return
}
if (!this.deck) {
return
}
this.current_page = pageNum-1 this.current_page = pageNum-1
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
}, },
scroll_to_page (page, behavior = 'smooth') { keyPressDebounce(e){
const section = this.$refs.slides.querySelector('section[data-page="' + page + '"]') clearTimeout(this.key_timeout)
if (section) { this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
section.scrollIntoView({behavior: behavior, block: 'start'})
}
}, },
keyPress (e) { keyPress(e) {
// Alt+Left is the browser's back button and Ctrl/Cmd+Arrow moves a caret in a if (e.key === 'ArrowRight') {
// form control, so only a bare arrow key pages. this.nextPage()
if (e.ctrlKey || e.metaKey || e.altKey) { } else if (e.key === 'ArrowLeft') {
return 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
// These only show or hide the shortcut list, so they still work when a control
// has focus.
if (e.key === 'Escape') {
this.show_shortcuts = false
return
}
if (e.key === '?') {
this.show_shortcuts = !this.show_shortcuts
return
}
// The reader listens on window, so a key pressed while a control has focus would
// otherwise page as well as activate that control.
if (e.target && e.target.closest && e.target.closest('input, select, textarea, button')) {
return
}
if (this.key_locked) {
return
}
this.key_locked = true
this.key_timeout = setTimeout(() => { this.key_locked = false }, KEY_THROTTLE_MS)
// Left and Right change pages. Up and Down are left to the browser's default
// scrolling: the page is taller than the viewport, and intercepting them here would
// double up with the native scroll. In continuous mode the observer records whichever
// page is topmost, so scrolling inside a page does not change the saved page.
const key = e.key.toLowerCase()
switch (key) {
case 'arrowright':
case 'n':
this.nextPage('auto')
return
case 'arrowleft':
case 'p':
this.prevPage('auto')
return
case ' ':
// Space would otherwise scroll the page as well as turning it.
e.preventDefault?.()
if (e.shiftKey) {
this.prevPage('auto')
} else {
this.nextPage('auto')
}
return
case 'home':
case 'end':
// Same for Home/End, which the browser would scroll to the page edges.
e.preventDefault?.()
this.setPage(key === 'home' ? 1 : this.pages)
return
case 'm':
this.setMode(this.mode === 'paged' ? 'scroll' : 'paged')
return
}
},
// Continuous mode: reveal is not used for visibility here, so the page in view is
// worked out from what is actually intersecting the viewport.
start_scroll () {
this.visible.clear()
this.inview.clear()
this.page_observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
this.visible.set(entry.target, entry.boundingClientRect.top)
this.inview.add(Number(entry.target.dataset.page))
} else {
this.visible.delete(entry.target)
this.inview.delete(Number(entry.target.dataset.page))
}
}
if (this.visible.size === 0) {
return
}
const top = [...this.visible.entries()].sort((a, b) => a[1] - b[1])[0][0]
const page = Number(top.dataset.page)
if (page && page !== this.current_page + 1) {
this.current_page = page - 1
this.queue_save(page - 1)
}
}, { threshold: 0 })
this.growth_observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.extend_window()
}
}, { rootMargin: '1200px 0px' })
this.observe_pages()
if (this.$refs.sentinel) {
this.growth_observer.observe(this.$refs.sentinel)
}
this.scroll_to_page(this.current_page + 1)
},
observe_pages () {
this.$refs.slides.querySelectorAll('section[data-page]').forEach((section) => {
this.page_observer.observe(section)
})
},
extend_window () {
if (this.rendered >= this.pages) {
return
}
this.rendered = Math.min(this.pages, this.rendered + WINDOW_SIZE)
this.$nextTick(() => this.observe_pages())
},
// One write per burst of scrolling rather than one per intersection.
queue_save (page) {
clearTimeout(this.save_timeout)
this.save_timeout = setTimeout(() => {
api.put(this.set_read_url, {page: page})
}, 500)
},
start_paged () {
this.deck = Reveal(this.$refs.comic_box) this.deck = Reveal(this.$refs.comic_box)
this.deck.initialize({ this.deck.initialize({
controls: false, controls: false,
@@ -369,99 +130,29 @@ export default {
plugins: [ ] plugins: [ ]
}).then(() => { }).then(() => {
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
this.slideChanged = (event) => { api.put(set_read_url, {page: this.current_page})
this.deck.on( 'slidechanged', (event) => {
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'}) this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
api.put(this.set_read_url, {page: event.indexh}) api.put(set_read_url, {page: event.indexh})
} });
this.deck.on('slidechanged', this.slideChanged);
}) })
},
async setMode (mode) {
if (mode === this.mode) {
return
}
this.$store.commit('setReaderMode', mode) this.hammertime = new Hammer(this.$refs.comic_box, {})
this.teardown() this.hammertime.on('swipeleft', (_e, self=this) => {
await this.$nextTick() self.nextPage()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
if (mode === 'scroll') {
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
await this.$nextTick()
this.start_scroll()
} else {
this.start_paged()
}
},
teardown () {
clearTimeout(this.save_timeout)
this.inview.clear()
if (this.page_observer) {
this.page_observer.disconnect()
this.page_observer = null
}
if (this.growth_observer) {
this.growth_observer.disconnect()
this.growth_observer = null
}
this.visible.clear()
if (this.deck) {
if (this.slideChanged) {
this.deck.off('slidechanged', this.slideChanged)
}
try {
this.deck.destroy()
} catch (e) {
console.log(e)
}
this.deck = null
}
}
},
watch: {
'current_page' (new_page) {
this.paginate_page = new_page + 1
}
},
mounted () {
let comic_data_url = '/api/read/' + this.selector + '/'
window.addEventListener('keydown', this.keyPress)
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
if (this.mode === 'scroll') {
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
return this.$nextTick().then(() => this.start_scroll())
}
return this.$nextTick().then(() => this.start_paged())
})
.then(() => {
api.put(this.set_read_url, {page: this.current_page})
}) })
.catch((error) => {console.log(error)}) .catch((error) => {console.log(error)})
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()
})
}, },
beforeUnmount() { beforeUnmount() {
window.removeEventListener('keydown', this.keyPress) window.removeEventListener('keyup', this.keyPressDebounce)
clearTimeout(this.key_timeout)
this.teardown()
try { try {
this.hammertime.off('swipeleft') this.hammertime.off('swipeleft')
this.hammertime.off('swiperight') this.hammertime.off('swiperight')
@@ -488,64 +179,4 @@ export default {
section { section {
padding-bottom: 60px; padding-bottom: 60px;
} }
.shortcuts {
position: fixed;
right: 12px;
bottom: 64px;
max-width: 340px;
padding: 10px 12px;
border: 1px solid #adb5bd;
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
font-size: 0.85rem;
z-index: 20;
}
.shortcuts-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.shortcuts td {
padding: 2px 8px 2px 0;
vertical-align: top;
}
.shortcuts kbd {
font-family: inherit;
white-space: nowrap;
}
/* reveal.css is deliberately not loaded, so visibility is the `hidden` attribute and
reveal's own transition classes do nothing. A transition cannot run across
display:none -> block, so paged mode uses an animation on the slide that becomes
present; continuous mode uses a transition driven by the IntersectionObserver. */
.reveal section.present img {
animation: page-fade 0.3s ease;
}
.continuous img {
opacity: 0;
transition: opacity 0.3s ease;
}
.continuous img.shown {
opacity: 1;
}
@keyframes page-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.reveal section.present img,
.continuous img {
animation: none;
transition: none;
}
}
</style> </style>
+2 -14
View File
@@ -6,8 +6,7 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<!-- Show these links only when user is authenticated --> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-if="isAuthenticated">
<li class="nav-item"> <li class="nav-item">
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link> <router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
</li> </li>
@@ -27,12 +26,6 @@
<a class="nav-link" @click="logout">Log Out</a> <a class="nav-link" @click="logout">Log Out</a>
</li> </li>
</ul> </ul>
<!-- Show login link when user is not authenticated -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-else>
<li class="nav-item">
<router-link :to="{name: 'login'}" class="nav-link">Log In</router-link>
</li>
</ul>
</div> </div>
</div> </div>
</nav> </nav>
@@ -49,14 +42,9 @@ export default {
visible: false visible: false
} }
}, },
computed: {
isAuthenticated() {
return this.$store.getters.isAuthenticated;
}
},
methods: { methods: {
logout () { logout () {
store.dispatch('logout') store.commit('logOut')
router.push({name: 'login'}) router.push({name: 'login'})
} }
} }
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="container" ref="pdfContainer">
<div class="row 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>
</div>
</div>
<div class="row navButtons pb-2">
<comic-paginate
v-model="page"
:page_count="numPages"
@setPage="setPage"
@prevComic="prevComic"
@nextComic="nextComic"
/>
</div>
</template>
<script>
import pdfvuer from 'pdfvuer'
import api from "@/api";
import * as Hammer from 'hammerjs'
import ComicPaginate from "@/components/ComicPaginate";
export default {
name: "ThePdfReader",
components: {
ComicPaginate,
pdf: pdfvuer
},
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, {})
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.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>
+2 -3
View File
@@ -23,6 +23,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<caption>
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2> <h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
Mark selected issues as: Mark selected issues as:
<select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected"> <select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
@@ -30,6 +31,7 @@
<option value="mark_read">Read</option> <option value="mark_read">Read</option>
<option value="mark_unread">Un-Read</option> <option value="mark_unread">Un-Read</option>
</select> </select>
</caption>
</div> </div>
<div class="row"> <div class="row">
<table class="table table-striped table-bordered"> <table class="table table-striped table-bordered">
@@ -175,9 +177,6 @@ export default {
this.feed_id = response.data.feed_id this.feed_id = response.data.feed_id
}) })
}, },
beforeUnmount() {
clearTimeout(this.timeout)
},
} }
</script> </script>
+4 -9
View File
@@ -15,20 +15,15 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
/* import specific icons */ /* import specific icons */
import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh} from '@fortawesome/free-solid-svg-icons' import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck} from '@fortawesome/free-solid-svg-icons'
library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh) library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck)
import router from './router' import router from './router'
import store from './store' import store from './store'
const app = Vue.createApp(App) Vue.createApp(App)
.use(ToastPlugin) .use(ToastPlugin)
.use(store) .use(store)
.use(router) .use(router)
.component('font-awesome-icon', FontAwesomeIcon) .component('font-awesome-icon', FontAwesomeIcon)
.mount('#app')
// Validate the cookie before the first navigation, so the router guards see the
// real session instead of whatever is left in localStorage.
store.dispatch('restoreSession').finally(() => {
app.mount('#app')
})
+6 -33
View File
@@ -1,5 +1,4 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import store from '@/store'
const ReadView = () => import('@/views/ReadView') const ReadView = () => import('@/views/ReadView')
const RecentView = () => import('@/views/RecentView') const RecentView = () => import('@/views/RecentView')
@@ -9,26 +8,6 @@ const UserView = () => import('@/views/UserView')
const LoginView = () => import('@/views/LoginView') const LoginView = () => import('@/views/LoginView')
const HistoryView = () => import('@/views/HistoryView') const HistoryView = () => import('@/views/HistoryView')
// Navigation guard to check if user is authenticated
function requireAuth(to) {
if (!store.getters.isAuthenticated) {
return {
name: 'login',
query: { next: to.fullPath, error: 'Please log in to access this page' }
};
}
}
// Navigation guard to check if user is admin
function requireAdmin(to) {
if (!store.getters.isAuthenticated || !store.getters.is_superuser) {
return {
name: 'login',
query: { next: to.fullPath, error: 'Admin access required' }
};
}
}
const routes = [ const routes = [
{ {
path: '/', path: '/',
@@ -41,15 +20,13 @@ const routes = [
path: '/browse/:selector?', path: '/browse/:selector?',
name: 'browse', name: 'browse',
component: BrowseView, component: BrowseView,
props: true, props: true
beforeEnter: requireAuth
}, },
{ {
path: '/read/:selector', path: '/read/:selector',
name: 'read', name: 'read',
component: ReadView, component: ReadView,
props: true, props: true
beforeEnter: requireAuth
}, },
{ {
path: '/login', path: '/login',
@@ -59,27 +36,23 @@ const routes = [
{ {
path: '/recent', path: '/recent',
name: 'recent', name: 'recent',
component: RecentView, component: RecentView
beforeEnter: requireAuth
}, },
{ {
path: '/history', path: '/history',
name: 'history', name: 'history',
component: HistoryView, component: HistoryView
beforeEnter: requireAuth
}, },
{ {
path: '/account', path: '/account',
name: 'account', name: 'account',
component: AccountView, component: AccountView
beforeEnter: requireAuth
}, },
{ {
path: '/user/:userid?', path: '/user/:userid?',
name: 'user', name: 'user',
component: UserView, component: UserView,
props: true, props: true
beforeEnter: requireAdmin
}, },
{ {
path: '/about', path: '/about',
+65 -96
View File
@@ -1,11 +1,17 @@
import { createStore } from 'vuex' import { createStore } from 'vuex'
import axios from 'axios'
import jwtDecode from 'jwt-decode'
import {useToast} from "vue-toast-notification"; import {useToast} from "vue-toast-notification";
import router from "@/router"; import router from "@/router";
import api from "@/api"; import api from "@/api";
// Tokens live in HttpOnly cookies set by the backend, so JavaScript cannot read function get_jwt_from_storage(){
// them. Only the user profile is kept in localStorage, to avoid a blank page try {
// before the first request has been made. return JSON.parse(localStorage.getItem('t'))
} catch {
return null
}
}
function get_user_from_storage(){ function get_user_from_storage(){
try { try {
return JSON.parse(localStorage.getItem('u')) return JSON.parse(localStorage.getItem('u'))
@@ -14,27 +20,11 @@ function get_user_from_storage(){
} }
} }
// Reading mode is a preference rather than part of the session, so it is kept on its own.
function get_reader_mode_from_storage(){
const mode = localStorage.getItem('reader_mode')
return mode === 'scroll' ? 'scroll' : 'paged'
}
function session_expired_query(next){
return {
name: 'login',
query: {
next: next,
error: 'Your session has expired. Please log in again.'
}
}
}
export default createStore({ export default createStore({
state: { state: {
jwt: get_jwt_from_storage(),
filters: {}, filters: {},
user: get_user_from_storage(), user: get_user_from_storage(),
reader_mode: get_reader_mode_from_storage(),
classifications: [ classifications: [
{label: 'G', value: '0'}, {label: 'G', value: '0'},
{label: 'PG', value: '1'}, {label: 'PG', value: '1'},
@@ -50,27 +40,23 @@ export default createStore({
} else { } else {
return state.user.is_superuser return state.user.is_superuser
} }
},
isAuthenticated (state) {
return state.user !== null
} }
}, },
mutations: { mutations: {
updateToken(state, newToken){
localStorage.setItem('t', JSON.stringify(newToken));
state.jwt = newToken;
},
logOut(state){ logOut(state){
localStorage.removeItem('t');
localStorage.removeItem('u') localStorage.removeItem('u')
state.jwt = null;
state.user = null state.user = null
}, },
updateUser(state, userData){ updateUser(state, userData){
localStorage.setItem('u', JSON.stringify(userData)); localStorage.setItem('u', JSON.stringify(userData));
state.user = userData state.user = userData
}, },
setReaderMode(state, mode){
if (mode !== 'paged' && mode !== 'scroll') {
return
}
localStorage.setItem('reader_mode', mode);
state.reader_mode = mode
},
}, },
actions: { actions: {
obtainToken(context, {username, password}){ obtainToken(context, {username, password}){
@@ -78,77 +64,60 @@ export default createStore({
username: username, username: username,
password: password password: password
} }
// The response body is empty: the backend stores the tokens as HttpOnly cookies. axios.post('/api/token/', payload)
api.post('/api/token/', payload) .then((response)=>{
.then(() => api.get('/api/account')) context.commit('updateToken', response.data);
.then((response) => { api.get('/api/account').then(response => {
context.commit('updateUser', response.data) context.commit('updateUser', response.data)
if ('next' in router.currentRoute.value.query) { })
router.push(router.currentRoute.value.query.next) if ('next' in router.currentRoute.value.query) {
} else { router.push(router.currentRoute.value.query.next)
router.push('browse') } else {
} router.push('browse')
}) }
.catch((error) => {
const $toast = useToast(); })
const data = error.response?.data ?? {}; .catch((error)=>{
if (data.detail) { const $toast = useToast();
$toast.error(data.detail, {position:'top'}); if (error.response.data.detail) {
} $toast.error(error.response.data.detail, {position:'top'});
if (data.username) { }
$toast.error("Username: " + data.username, {position:'top'}); if (error.response.data.username) {
} $toast.error("Username: " + error.response.data.username, {position:'top'});
if (data.password) { }
$toast.error("Password: " + data.password, {position:'top'}); if (error.response.data.password) {
} $toast.error("Password: " + error.response.data.password, {position:'top'});
}) }
})
}, },
refreshToken(){ refreshToken(){
if (!this.state.user) { const payload = {
return Promise.reject(new Error('Not logged in')); refresh: this.state.jwt.refresh
} }
return axios.post('/api/token/refresh/', payload)
// The refresh token is read from the cookie by the backend. .then((response)=>{
return api.post('/api/token/refresh/', {}) this.commit('updateToken', response.data)
.then((response) => response.data) })
.catch((error) => { .catch((error)=>{
console.error('Token refresh failed:', error); console.log(error)
this.commit('logOut'); // router.push({name: 'login', query: {area: 'store'}})
router.push(session_expired_query(router.currentRoute.value.fullPath)); })
return Promise.reject(error);
});
}, },
// Check that the cookie still identifies the user stored in localStorage. inspectToken(){
restoreSession(){ const token = this.state.jwt;
if (!this.state.user) { if(token){
return Promise.resolve(null); 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
}
} }
return api.get('/api/account')
.then((response) => {
this.commit('updateUser', response.data);
return response.data;
})
.catch((error) => {
if (error.response?.status === 401) {
this.commit('logOut');
router.push({ name: 'login' });
}
return null;
});
},
logout(){
const wasLoggedIn = this.state.user !== null;
this.commit('logOut');
if (!wasLoggedIn) {
return Promise.resolve(null);
}
return api.post('/api/token/blacklist/', {})
.catch((error) => {
console.error('Error blacklisting token:', error);
});
} }
}, },
modules: { modules: {
+1 -19
View File
@@ -3,11 +3,6 @@
<div class="row" v-if="!initialSetupRequired"> <div class="row" v-if="!initialSetupRequired">
<div class="col col-lg-4" /> <div class="col col-lg-4" />
<div class="col col-lg-4" id="login-col"> <div class="col col-lg-4" id="login-col">
<!-- Display error message if present -->
<div class="alert alert-danger" v-if="errorMessage">
{{ errorMessage }}
</div>
<form @submit="login" v-on:submit.prevent="onSubmit"> <form @submit="login" v-on:submit.prevent="onSubmit">
<label class="form-label" for="username">Username</label> <label class="form-label" for="username">Username</label>
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" /> <input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
@@ -39,8 +34,7 @@ export default {
username: '', username: '',
password: '', password: '',
password_alert: false, password_alert: false,
initialSetupRequired: false, initialSetupRequired: false
errorMessage: ''
} }
}, },
methods: { methods: {
@@ -49,23 +43,11 @@ export default {
} }
}, },
mounted() { mounted() {
// Check for error message in route query params
if (this.$route.query.error) {
this.errorMessage = this.$route.query.error;
}
// Check if initial setup is required
axios.get('/api/initial_setup/required/').then(response => { axios.get('/api/initial_setup/required/').then(response => {
if (response.data.required){ if (response.data.required){
this.initialSetupRequired = true this.initialSetupRequired = true
} }
}) })
},
// Clear error message when route changes
watch: {
'$route'(to) {
this.errorMessage = to.query.error || '';
}
} }
} }
</script> </script>
+20 -6
View File
@@ -1,38 +1,52 @@
<template> <template>
<the-breadcrumbs :selector="selector" /> <the-breadcrumbs :selector="selector" />
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" /> <the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
<the-pdf-reader :selector="selector" v-if="pdf_loaded" :key="selector" />
</template> </template>
<script> <script>
import TheBreadcrumbs from "@/components/TheBreadcrumbs"; import TheBreadcrumbs from "@/components/TheBreadcrumbs";
import TheComicReader from "@/components/TheComicReader"; import TheComicReader from "@/components/TheComicReader";
import api from "@/api"; import api from "@/api";
import ThePdfReader from "@/components/ThePdfReader";
export default { export default {
name: "ReadView", name: "ReadView",
components: {TheComicReader, TheBreadcrumbs}, components: {ThePdfReader, TheComicReader, TheBreadcrumbs},
props: { props: {
selector: String selector: String
}, },
data () { data () {
return { return {
comic_data: {},
comic_loaded: false, comic_loaded: false,
pdf_loaded: false
} }
}, },
methods: { methods: {
checkComicExists() { updateType() {
api.get('/api/read/' + this.selector + '/type/') let comic_data_url = '/api/read/' + this.selector + '/type/'
.then(() => { 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.comic_loaded = true
this.pdf_loaded = false
}
}) })
.catch((error) => {console.log(error)}) .catch((error) => {console.log(error)})
} }
}, },
mounted () { mounted () {
this.checkComicExists() this.updateType()
},
beforeUpdate() {
this.updateType()
} }
} }
</script> </script>
<style scoped> <style scoped>
</style> </style>
+2 -1
View File
@@ -16,6 +16,7 @@ import UserEdit from "@/components/UserEdit";
import alertMessages from "@/components/AlertMessages"; import alertMessages from "@/components/AlertMessages";
import AddUser from "@/components/AddUser"; import AddUser from "@/components/AddUser";
import router from "@/router"; import router from "@/router";
import store from "@/store";
const default_crumbs = [ const default_crumbs = [
{id: 0, selector: '', name: 'Home'}, {id: 0, selector: '', name: 'Home'},
@@ -67,7 +68,7 @@ export default {
} }
}, },
watch: { watch: {
$route() { $route(to, from) {
this.updateUsers() this.updateUsers()
this.crumbs = [...default_crumbs] this.crumbs = [...default_crumbs]
if (this.userid){ if (this.userid){
-114
View File
@@ -1,114 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { api, store, router, interceptors } = vi.hoisted(() => {
const interceptors = { request: [], response: [] }
const api = {
request: vi.fn(),
interceptors: {
request: { use: (fn) => interceptors.request.push(fn) },
response: { use: (ok, err) => interceptors.response.push({ ok, err }) },
},
}
return {
api,
store: { dispatch: vi.fn(), commit: vi.fn() },
router: { push: vi.fn() },
interceptors,
}
})
vi.mock('axios', () => ({ default: { create: () => api } }))
vi.mock('@/store', () => ({ default: store }))
vi.mock('@/router', () => ({ default: router }))
import axiosApi from '@/api'
const requestInterceptor = interceptors.request[0]
const { ok: successHandler, err: errorHandler } = interceptors.response[0]
function error401(url) {
return { response: { status: 401 }, config: { url } }
}
beforeEach(() => {
document.cookie = 'csrftoken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
api.request.mockReset()
store.dispatch.mockReset()
store.commit.mockReset()
router.push.mockReset()
})
describe('request interceptor', () => {
it('uses the cookie session, so it sends no Authorization header', () => {
expect(axiosApi).toBe(api)
expect(requestInterceptor({ headers: {} }).headers).not.toHaveProperty('Authorization')
})
it('adds the CSRF header when a csrftoken cookie exists', () => {
document.cookie = 'csrftoken=abc123; path=/'
expect(requestInterceptor({ headers: {} }).headers['X-CSRFToken']).toBe('abc123')
})
it('leaves the headers untouched when there is no CSRF cookie', () => {
expect(requestInterceptor({ headers: {} }).headers).not.toHaveProperty('X-CSRFToken')
})
})
describe('response interceptor', () => {
it('passes successful responses through', async () => {
const response = { data: [1, 2] }
expect(await successHandler(response)).toBe(response)
expect(store.dispatch).not.toHaveBeenCalled()
})
it('refreshes the session and retries once after a 401', async () => {
store.dispatch.mockResolvedValue(undefined)
api.request.mockResolvedValue({ data: 'retried' })
const error = error401('/api/browse/')
expect(await errorHandler(error)).toEqual({ data: 'retried' })
expect(store.dispatch).toHaveBeenCalledWith('refreshToken')
expect(api.request).toHaveBeenCalledWith(error.config)
expect(error.config._retried).toBe(true)
})
it('does not retry a request that has already been retried', async () => {
const error = error401('/api/browse/')
error.config._retried = true
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.dispatch).not.toHaveBeenCalled()
})
it('does not retry the token endpoints, which would loop', async () => {
const error = error401('/api/token/refresh/')
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.dispatch).not.toHaveBeenCalled()
})
it('logs out and goes to the login page when the refresh fails', async () => {
store.dispatch.mockRejectedValue(new Error('expired'))
const error = error401('/api/browse/')
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.commit).toHaveBeenCalledWith('logOut')
expect(router.push).toHaveBeenCalledWith({
name: 'login',
query: {
next: '/api/browse/',
error: 'Your session has expired. Please log in again.',
},
})
})
it('leaves other error statuses alone', async () => {
const error = { response: { status: 403 }, config: { url: '/api/browse/' } }
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.dispatch).not.toHaveBeenCalled()
})
})
-133
View File
@@ -1,133 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn() }))
const store = vi.hoisted(() => ({ state: { filters: {} }, getters: { isAuthenticated: true, is_superuser: false } }))
vi.mock('@/api', () => ({ default: api }))
vi.mock('@/store', () => ({ default: store }))
import TheComicList from '@/components/TheComicList.vue'
const BOOKS = [
{ selector: 'a1', title: 'Alpha', type: 'ComicBook', finished: true, unread: false, thumbnail: '' },
{ selector: 'b2', title: 'Beta', type: 'ComicBook', finished: false, unread: true, thumbnail: '' },
{ selector: 'c3', title: 'Gamma Subfolder', type: 'Directory', thumbnail: '' },
]
function mountList(props = {}) {
return mount(TheComicList, {
props: { selector: props.selector ?? '' },
global: {
stubs: {
'font-awesome-icon': true,
ComicCard: {
name: 'ComicCard',
props: ['data'],
template: '<div class="card-stub" :data-selector="data.title">{{ data.title }}</div>',
},
},
},
})
}
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.put.mockReset()
api.get.mockResolvedValue({ data: [...BOOKS] })
store.state.filters = {}
})
describe('TheComicList', () => {
it('loads the home listing when it is mounted', async () => {
const wrapper = mountList()
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/browse/')
expect(wrapper.text()).toContain('Alpha')
})
it('loads the listing for the folder it was given', async () => {
const wrapper = mountList({ selector: 'c3' })
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/browse/c3/')
expect(wrapper.vm.comics).toHaveLength(3)
})
it('filters by name without changing the loaded data', async () => {
const wrapper = mountList()
await flushPromises()
wrapper.vm.filters.search_string = 'beta'
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Beta'])
wrapper.vm.filters.search_string = 'ga'
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Gamma Subfolder'])
})
it('filters read and unread entries separately', async () => {
const wrapper = mountList()
await flushPromises()
wrapper.vm.filters.filter_read = true
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Alpha'])
wrapper.vm.filters.filter_read = false
wrapper.vm.filters.filter_unread = true
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Beta'])
})
it('keeps the filters in the store so they survive a folder change', async () => {
const wrapper = mountList({ selector: 'c3' })
await flushPromises()
wrapper.vm.filters.search_string = 'alpha'
expect(store.state.filters.c3.search_string).toBe('alpha')
})
it('scans the folder on disk and uses the returned listing', async () => {
let resolvePost
api.post.mockImplementation(() => new Promise((resolve) => { resolvePost = resolve }))
const wrapper = mountList({ selector: 'c3' })
await flushPromises()
const button = wrapper.find('button[title="Read this folder from disk again"]')
button.trigger('click')
await wrapper.vm.$nextTick()
expect(api.post).toHaveBeenCalledWith('/api/browse/scan/', { selector: 'c3' })
expect(wrapper.vm.scanning).toBe(true)
expect(button.element.disabled).toBe(true)
resolvePost({ data: [{ selector: 'd4', title: 'Delta', type: 'ComicBook' }] })
await flushPromises()
expect(wrapper.vm.comics).toHaveLength(1)
expect(wrapper.vm.scanning).toBe(false)
})
it('marks every book in the folder, ignoring subfolders', async () => {
const wrapper = mountList()
await flushPromises()
api.put.mockResolvedValue({ data: [] })
wrapper.vm.markAll('mark_read')
await flushPromises()
expect(api.put).toHaveBeenCalledWith('/api/action/mark_read/', { selectors: ['a1', 'b2'] })
})
it('marks everything before the card that asked for it', async () => {
const wrapper = mountList()
await flushPromises()
api.put.mockResolvedValue({ data: [] })
wrapper.vm.markPreviousRead('b2')
await flushPromises()
expect(api.put).toHaveBeenCalledWith('/api/action/mark_read/', { selectors: ['a1', 'b2'] })
})
})
-520
View File
@@ -1,520 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { reactive } from 'vue'
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
const router = vi.hoisted(() => ({ push: vi.fn() }))
const deck = vi.hoisted(() => ({
initialize: vi.fn(() => Promise.resolve()),
slide: vi.fn(),
on: vi.fn(),
off: vi.fn(),
destroy: vi.fn(),
isFirstSlide: vi.fn(() => false),
isLastSlide: vi.fn(() => false),
}))
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
const reveal = vi.hoisted(() => vi.fn(() => deck))
const store = vi.hoisted(() => ({
state: { reader_mode: 'paged' },
commit: vi.fn(),
}))
const observers = []
// jsdom has no IntersectionObserver, so the tests drive the callbacks by hand.
class FakeIntersectionObserver {
constructor(callback, options) {
this.callback = callback
this.options = options
this.targets = []
this.disconnected = false
observers.push(this)
}
observe(target) {
if (!this.targets.includes(target)) this.targets.push(target)
}
unobserve(target) { this.targets = this.targets.filter((item) => item !== target) }
disconnect() { this.disconnected = true; this.targets = [] }
fire(entries) { this.callback(entries, this) }
}
vi.mock('@/api', () => ({ default: api }))
vi.mock('reveal.js', () => ({ default: reveal }))
vi.mock('hammerjs', () => ({ default: vi.fn(function () { return hammertime }) }))
import TheComicReader from '@/components/TheComicReader.vue'
const COMIC = {
title: 'Alpha',
last_read_page: 2,
pages: 5,
prev_comic: { route: 'browse', selector: 'p1' },
next_comic: { route: 'browse', selector: 'n1' },
}
const mounted = []
function mountReader() {
const wrapper = mount(TheComicReader, {
props: { selector: 'a1' },
global: {
mocks: { $router: router, $store: store },
stubs: { 'comic-paginate': true },
},
})
mounted.push(wrapper)
return wrapper
}
beforeEach(() => {
vi.useFakeTimers()
HTMLElement.prototype.scrollIntoView = vi.fn()
api.get.mockReset()
api.put.mockReset()
api.get.mockResolvedValue({ data: { ...COMIC } })
api.put.mockResolvedValue({ data: {} })
router.push.mockReset()
store.state = reactive({ reader_mode: 'paged' })
store.commit.mockReset()
store.commit.mockImplementation((name, value) => {
if (name === 'setReaderMode' && (value === 'paged' || value === 'scroll')) {
store.state.reader_mode = value
}
})
observers.length = 0
globalThis.IntersectionObserver = FakeIntersectionObserver
reveal.mockClear()
for (const fn of Object.values(deck)) {
if (typeof fn === 'function') fn.mockClear?.()
}
for (const fn of Object.values(hammertime)) fn.mockClear()
})
afterEach(() => {
mounted.forEach((wrapper) => {
if (!wrapper.vm?.isUnmounted) wrapper.unmount()
})
mounted.length = 0
})
describe('TheComicReader', () => {
it('loads the book, starts the deck and records the page it resumed on', async () => {
const wrapper = mountReader()
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/read/a1/')
expect(deck.initialize).toHaveBeenCalled()
expect(wrapper.vm.current_page).toBe(2)
expect(deck.slide).toHaveBeenCalledWith(2)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 2 })
})
it('keeps the page indicator in step with the deck', async () => {
const wrapper = mountReader()
await flushPromises()
wrapper.vm.setPage(4)
expect(deck.slide).toHaveBeenCalledWith(3)
await flushPromises()
expect(wrapper.vm.paginate_page).toBe(4)
})
it('acts on the first arrow key and ignores held keys until the throttle expires', async () => {
const wrapper = mountReader()
await flushPromises()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
expect(wrapper.vm.current_page).toBe(3)
expect(deck.slide).toHaveBeenCalledTimes(2)
vi.advanceTimersByTime(100)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
expect(wrapper.vm.current_page).toBe(4)
expect(deck.slide).toHaveBeenCalledTimes(3)
})
it('moves to the next book when the last slide is reached', async () => {
const wrapper = mountReader()
await flushPromises()
deck.isLastSlide.mockReturnValueOnce(true)
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'n1' } })
})
it('moves to the previous book when the first slide is reached', async () => {
const wrapper = mountReader()
await flushPromises()
deck.isFirstSlide.mockReturnValueOnce(true)
wrapper.vm.keyPress({ key: 'ArrowLeft' })
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'p1' } })
})
it('records the page again whenever the visible slide changes', async () => {
mountReader()
await flushPromises()
const handler = deck.on.mock.calls[0][1]
api.put.mockClear()
handler({ indexh: 4 })
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
})
it('removes every listener and the deck when it is unmounted', async () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const removeSpy = vi.spyOn(window, 'removeEventListener')
const wrapper = mountReader()
await flushPromises()
expect(addSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
expect(deck.destroy).toHaveBeenCalled()
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
expect(hammertime.off).toHaveBeenCalledWith('swiperight')
expect(hammertime.off).toHaveBeenCalledWith('tap')
addSpy.mockRestore()
removeSpy.mockRestore()
})
it('leaves arrow keys with a modifier to the browser', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowLeft', altKey: true })
expect(deck.slide).not.toHaveBeenCalled()
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(wrapper.vm.current_page).toBe(3)
})
it('ignores arrow keys pressed while a form control has focus', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('input') })
expect(deck.slide).not.toHaveBeenCalled()
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('button') })
expect(deck.slide).not.toHaveBeenCalled()
})
it('leaves Up and Down to the browser so they scroll within the page', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
api.put.mockClear()
wrapper.vm.keyPress({ key: 'ArrowDown' })
wrapper.vm.keyPress({ key: 'ArrowUp' })
// Nothing is intercepted, so the native scroll is not doubled up and the recorded
// page stays where the reader actually is.
expect(wrapper.vm.current_page).toBe(2)
expect(deck.slide).not.toHaveBeenCalled()
expect(api.put).not.toHaveBeenCalled()
})
it('shows the shortcut list with ? and hides it again', async () => {
const wrapper = mountReader()
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(false)
wrapper.vm.keyPress({ key: '?' })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(true)
wrapper.vm.keyPress({ key: '?' })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(false)
})
it('hides the shortcut list with Escape even when a control has focus', async () => {
const wrapper = mountReader()
await flushPromises()
wrapper.vm.keyPress({ key: '?' })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(true)
wrapper.vm.keyPress({ key: 'Escape', target: document.createElement('input') })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(false)
})
it('toggles the shortcut list from the ? button', async () => {
const wrapper = mountReader()
await flushPromises()
const button = wrapper.find('button[aria-pressed]')
await button.trigger('click')
expect(wrapper.find('.shortcuts').exists()).toBe(true)
expect(button.attributes('aria-pressed')).toBe('true')
})
it('pages with Space and Shift+Space', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
const preventDefault = vi.fn()
wrapper.vm.keyPress({ key: ' ', preventDefault })
expect(preventDefault).toHaveBeenCalled()
expect(deck.slide).toHaveBeenCalledWith(3)
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: ' ', shiftKey: true, preventDefault })
expect(deck.slide).toHaveBeenCalledWith(2)
})
it('pages with n and p whatever the case', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'n' })
expect(deck.slide).toHaveBeenCalledWith(3)
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'P' })
expect(deck.slide).toHaveBeenCalledWith(2)
})
it('jumps to the first and last page with Home and End', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
const preventDefault = vi.fn()
wrapper.vm.keyPress({ key: 'Home', preventDefault })
expect(preventDefault).toHaveBeenCalled()
expect(deck.slide).toHaveBeenCalledWith(0)
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'End', preventDefault })
expect(deck.slide).toHaveBeenCalledWith(4)
})
it('switches reading mode with m', async () => {
const wrapper = mountReader()
await flushPromises()
wrapper.vm.keyPress({ key: 'm' })
await flushPromises()
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'scroll')
expect(wrapper.vm.mode).toBe('scroll')
})
it('shows the top of the page when changing pages in paged mode', async () => {
const wrapper = mountReader()
await flushPromises()
const scrollIntoView = HTMLElement.prototype.scrollIntoView
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' })
})
it('does not move the deck when a key arrives before reveal has initialized', async () => {
const wrapper = mountReader()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(deck.slide).not.toHaveBeenCalled()
await flushPromises()
expect(deck.initialize).toHaveBeenCalled()
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(wrapper.vm.current_page).toBe(3)
expect(deck.slide).toHaveBeenCalledWith(3)
})
})
describe('TheComicReader in continuous mode', () => {
function mountContinuous(pageCount) {
store.state.reader_mode = 'scroll'
api.get.mockResolvedValue({ data: { ...COMIC, pages: pageCount } })
return mountReader()
}
function growthObserver() {
return observers.find((observer) => observer.options.rootMargin)
}
function pageObserver() {
return observers.find((observer) => !observer.options.rootMargin)
}
it('renders only the first window of pages and does not start a deck', async () => {
const wrapper = mountContinuous(45)
await flushPromises()
expect(wrapper.vm.rendered).toBe(20)
expect(wrapper.findAll('section[data-page]')).toHaveLength(20)
expect(reveal).not.toHaveBeenCalled()
})
it('renders far enough to include the page it resumed on', async () => {
store.state.reader_mode = 'scroll'
api.get.mockResolvedValue({ data: { ...COMIC, last_read_page: 30, pages: 45 } })
const wrapper = mountReader()
await flushPromises()
expect(wrapper.vm.rendered).toBe(31)
expect(wrapper.findAll('section[data-page]')).toHaveLength(31)
})
it('grows the window when the reader reaches the end of what is rendered', async () => {
const wrapper = mountContinuous(45)
await flushPromises()
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(wrapper.vm.rendered).toBe(40)
expect(wrapper.findAll('section[data-page]')).toHaveLength(40)
expect(pageObserver().targets).toHaveLength(40)
})
it('does not grow past the last page', async () => {
const wrapper = mountContinuous(25)
await flushPromises()
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(wrapper.vm.rendered).toBe(25)
expect(wrapper.find('.sentinel').exists()).toBe(false)
})
it('records the page at the top of the viewport', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
pageObserver().fire([
{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 10 } },
{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 400 } },
])
expect(wrapper.vm.current_page).toBe(4)
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
})
it('keeps the recorded page while scrolling inside a page taller than the viewport', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 0 } }])
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
// Scrolling down inside that page leaves it topmost, so nothing is written.
api.put.mockClear()
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: -300 } }])
vi.advanceTimersByTime(500)
expect(wrapper.vm.current_page).toBe(4)
expect(api.put).not.toHaveBeenCalled()
// The recorded page only changes when the next page becomes the topmost one.
observer.fire([
{ isIntersecting: false, target: sections[4].element },
{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 0 } },
])
vi.advanceTimersByTime(500)
expect(wrapper.vm.current_page).toBe(5)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 5 })
})
it('writes once for a burst of scrolling rather than once per intersection', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 400 } }])
observer.fire([{ isIntersecting: true, target: sections[6].element, boundingClientRect: { top: 0 } }])
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledTimes(1)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 6 })
})
it('marks the page in view as shown so it fades in', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(sections[2].find('img').classes()).toContain('shown')
expect(sections[0].find('img').classes()).not.toContain('shown')
observer.fire([{ isIntersecting: false, target: sections[2].element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(sections[2].find('img').classes()).not.toContain('shown')
})
it('scrolls to a page chosen from the pager, rendering it first if it is outside the window', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
const target = wrapper.findAll('section[data-page]')[19].element
const spy = vi.spyOn(target, 'scrollIntoView')
wrapper.vm.setPage(20)
await flushPromises()
expect(wrapper.vm.rendered).toBe(20)
expect(spy).toHaveBeenCalled()
})
it('disconnects both observers when it is unmounted', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
wrapper.unmount()
expect(observers.every((observer) => observer.disconnected)).toBe(true)
})
it('commits the mode change and rebuilds the deck when switching back to one page at a time', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
await wrapper.vm.setMode('paged')
await flushPromises()
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'paged')
expect(observers.every((observer) => observer.disconnected)).toBe(true)
expect(reveal).toHaveBeenCalled()
expect(wrapper.findAll('section[data-page]')).toHaveLength(30)
})
})
-107
View File
@@ -1,107 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const api = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@/api', () => ({ default: api }))
import HistoryTable from '@/components/HistoryTable.vue'
function mountTable() {
return mount(HistoryTable, { global: { stubs: { paginate: true, 'font-awesome-icon': true } } })
}
beforeEach(() => {
vi.useFakeTimers()
api.get.mockReset()
api.get.mockResolvedValue({ data: { count: 25, results: [{ id: 1, selector: 'a1', file_name: 'Alpha', last_read_time: new Date(), unread: false, finished: true, last_read_page: 3, page_count: 5 }] } })
})
afterEach(() => {
vi.useRealTimers()
})
describe('HistoryTable', () => {
it('requests the first page when it is mounted', async () => {
const wrapper = mountTable()
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/history/', { params: { page: 1, page_size: 10 } })
expect(wrapper.text()).toContain('Finished')
})
it('works out how many pages the result count implies', async () => {
const wrapper = mountTable()
await flushPromises()
expect(wrapper.vm.page_count).toBe(3)
wrapper.vm.page_size = 25
wrapper.vm.setPage(2)
await flushPromises()
expect(api.get).toHaveBeenLastCalledWith('/api/history/', { params: { page: 2, page_size: 25 } })
expect(wrapper.vm.page_count).toBe(1)
})
it('includes the search text once there is one', async () => {
const wrapper = mountTable()
await flushPromises()
wrapper.vm.search_text = 'alpha'
wrapper.vm.setPage(1)
await flushPromises()
expect(api.get).toHaveBeenLastCalledWith('/api/history/', {
params: { page: 1, page_size: 10, search_text: 'alpha' },
})
})
it('debounces typing so one burst of keystrokes makes one request', async () => {
const wrapper = mountTable()
await flushPromises()
expect(api.get).toHaveBeenCalledTimes(1)
wrapper.vm.debounceInput()
wrapper.vm.debounceInput()
wrapper.vm.debounceInput()
vi.advanceTimersByTime(500)
await flushPromises()
expect(api.get).toHaveBeenCalledTimes(2)
})
it('does not fire a pending debounce after the table is removed', async () => {
const wrapper = mountTable()
await flushPromises()
wrapper.vm.debounceInput()
wrapper.unmount()
vi.advanceTimersByTime(1000)
await flushPromises()
expect(api.get).toHaveBeenCalledTimes(1)
})
it('goes back to page one when the page number is no longer valid', async () => {
api.get.mockRejectedValueOnce({ response: { data: { detail: 'Invalid page.' } } })
const wrapper = mountTable()
await flushPromises()
expect(wrapper.vm.page).toBe(1)
expect(api.get).toHaveBeenCalledTimes(2)
})
it('shows the reading status for each entry', async () => {
const wrapper = mountTable()
await flushPromises()
expect(wrapper.vm.get_status({ unread: true })).toBe('Unread')
expect(wrapper.vm.get_status({ unread: null })).toBe('Unread')
expect(wrapper.vm.get_status({ unread: false, finished: true })).toBe('Finished')
expect(wrapper.vm.get_status({ unread: false, finished: false, last_read_page: 2, page_count: 5 })).toBe('2 / 5')
})
})
-50
View File
@@ -1,50 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
import store from '@/store'
import router from '@/router'
beforeEach(() => {
localStorage.clear()
store.commit('logOut')
})
describe('navigation guards', () => {
it('redirects an anonymous user to the login page', async () => {
await router.push('/account')
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.error).toBe('Please log in to access this page')
})
it('keeps the requested path so the user returns there after logging in', async () => {
await router.push('/history')
expect(router.currentRoute.value.query.next).toBe('/history')
})
it('allows an authenticated non-admin to reach the history page', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
await router.push('/history')
expect(router.currentRoute.value.name).toBe('history')
})
it('still requires admin for the user page', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
await router.push('/user')
expect(router.currentRoute.value.name).toBe('login')
})
it('allows an admin on the user page', async () => {
store.commit('updateUser', { id: 1, is_superuser: true })
await router.push('/user')
expect(router.currentRoute.value.name).toBe('user')
})
})
-127
View File
@@ -1,127 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { api, router } = vi.hoisted(() => ({
api: { get: vi.fn(), post: vi.fn() },
router: { currentRoute: { value: { query: {}, fullPath: '/account' } }, push: vi.fn() },
}))
vi.mock('@/api', () => ({ default: api }))
vi.mock('@/router', () => ({ default: router }))
import store from '@/store'
beforeEach(() => {
localStorage.clear()
store.commit('logOut')
api.get.mockReset()
api.post.mockReset()
router.push.mockReset()
})
describe('session state', () => {
it('is not authenticated when there is no stored profile', () => {
expect(store.getters.isAuthenticated).toBe(false)
expect(store.getters.is_superuser).toBe(false)
})
it('keeps the profile in localStorage so a reload is not a blank page', () => {
store.commit('updateUser', { id: 1, is_superuser: false })
expect(store.getters.isAuthenticated).toBe(true)
expect(JSON.parse(localStorage.getItem('u'))).toEqual({ id: 1, is_superuser: false })
})
it('logOut clears both the profile and localStorage', () => {
store.commit('updateUser', { id: 1, is_superuser: true })
store.commit('logOut')
expect(store.getters.isAuthenticated).toBe(false)
expect(localStorage.getItem('u')).toBeNull()
})
})
describe('reader mode', () => {
it('starts in paged mode when nothing is stored', async () => {
vi.resetModules()
const fresh = (await import('@/store')).default
expect(fresh.state.reader_mode).toBe('paged')
})
it('keeps the chosen mode in localStorage', () => {
store.commit('setReaderMode', 'scroll')
expect(store.state.reader_mode).toBe('scroll')
expect(localStorage.getItem('reader_mode')).toBe('scroll')
})
it('ignores a mode it does not recognise', () => {
store.commit('setReaderMode', 'scroll')
store.commit('setReaderMode', 'banana')
expect(store.state.reader_mode).toBe('scroll')
expect(localStorage.getItem('reader_mode')).toBe('scroll')
})
})
describe('restoreSession', () => {
it('refreshes the stored profile from the cookie session', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.get.mockResolvedValue({ data: { id: 1, is_superuser: true } })
await store.dispatch('restoreSession')
expect(api.get).toHaveBeenCalledWith('/api/account')
expect(store.getters.is_superuser).toBe(true)
})
it('logs out when the cookie no longer identifies the user', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.get.mockRejectedValue({ response: { status: 401 } })
await store.dispatch('restoreSession')
expect(store.getters.isAuthenticated).toBe(false)
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
})
it('does nothing when there is no stored profile', async () => {
await store.dispatch('restoreSession')
expect(api.get).not.toHaveBeenCalled()
})
})
describe('token handling', () => {
it('rejects and logs out when the refresh cookie is no longer valid', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.post.mockRejectedValue({ response: { status: 401 } })
await expect(store.dispatch('refreshToken')).rejects.toBeTruthy()
expect(store.getters.isAuthenticated).toBe(false)
expect(router.push).toHaveBeenCalledWith(expect.objectContaining({ name: 'login' }))
})
it('does not call the API when there is no session', async () => {
await expect(store.dispatch('refreshToken')).rejects.toBeTruthy()
expect(api.post).not.toHaveBeenCalled()
})
it('does not blacklist a token for a session that does not exist', async () => {
await store.dispatch('logout')
expect(api.post).not.toHaveBeenCalled()
expect(store.getters.isAuthenticated).toBe(false)
})
it('blacklists the token when a session existed', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.post.mockResolvedValue({ data: {} })
await store.dispatch('logout')
expect(api.post).toHaveBeenCalledWith('/api/token/blacklist/', {})
expect(store.getters.isAuthenticated).toBe(false)
})
})
+26
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"
]
}
-18
View File
@@ -1,18 +0,0 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'src')
export default defineConfig({
plugins: [vue()],
// The source imports components without the .vue extension (e.g. '@/views/BrowseView'),
// so mirror the extension list used by frontend/webpack.dev.js.
resolve: { extensions: ['.ts', '.js', '.vue', '.json'] },
test: {
environment: 'jsdom',
alias: { '@': SRC },
},
})
+10
View File
@@ -0,0 +1,10 @@
const { defineConfig } = require('@vue/cli-service')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
.BundleAnalyzerPlugin;
module.exports = defineConfig({
transpileDependencies: true,
configureWebpack: {
plugins: [new BundleAnalyzerPlugin()]
},
})
+11 -11
View File
@@ -3,11 +3,6 @@ const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker'); const BundleTracker = require('webpack-bundle-tracker');
const webpack = require('webpack') const webpack = require('webpack')
// The dev server port. Keep this and the publicPath in sync - django-webpack-loader
// builds the script URL from the publicPath written into webpack-stats.json.
const DEV_PORT = process.env.DEV_PORT || 8001
const DEV_PUBLIC_PATH = `http://localhost:${DEV_PORT}/`
module.exports = () => { module.exports = () => {
return { return {
@@ -16,7 +11,6 @@ module.exports = () => {
entry: path.resolve(__dirname, './src/main.js'), entry: path.resolve(__dirname, './src/main.js'),
output: { output: {
path: path.resolve(__dirname, './dist/bundles/'), path: path.resolve(__dirname, './dist/bundles/'),
publicPath: DEV_PUBLIC_PATH,
}, },
module: { module: {
rules: [ rules: [
@@ -24,6 +18,14 @@ module.exports = () => {
test: /\.vue$/, test: /\.vue$/,
use: 'vue-loader' use: 'vue-loader'
}, },
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{ {
test: /\.css$/, test: /\.css$/,
use: [ use: [
@@ -43,9 +45,8 @@ module.exports = () => {
plugins: [ plugins: [
new VueLoaderPlugin(), new VueLoaderPlugin(),
new BundleTracker({ new BundleTracker({
filename: 'webpack-stats.json', filename: './webpack-stats.json',
path: path.resolve(__dirname, './'), publicPath: 'http://localhost:8080/'
publicPath: DEV_PUBLIC_PATH
}), }),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL), 'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
@@ -53,11 +54,10 @@ module.exports = () => {
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }), new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }),
], ],
devServer: { devServer: {
port: DEV_PORT,
headers: { headers: {
"Access-Control-Allow-Origin":"*" "Access-Control-Allow-Origin":"*"
}, },
hot: true, hot: true,
} }
}; };
} }
+11 -5
View File
@@ -2,6 +2,7 @@ const path = require('path')
const { VueLoaderPlugin } = require('vue-loader') const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker'); const BundleTracker = require('webpack-bundle-tracker');
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const webpack = require('webpack') const webpack = require('webpack')
@@ -11,9 +12,7 @@ module.exports = (env = {}) => {
return { return {
mode: 'production', mode: 'production',
// No source maps in production: they are not served to browsers, but collectstatic devtool: 'hidden-source-map',
// copies them into /static/ so browser devtools would pull ~3.3 MiB of maps.
devtool: false,
entry: path.resolve(__dirname, './src/main.js'), entry: path.resolve(__dirname, './src/main.js'),
output: { output: {
path: path.resolve(__dirname, './dist/bundles/'), path: path.resolve(__dirname, './dist/bundles/'),
@@ -24,6 +23,14 @@ module.exports = (env = {}) => {
test: /\.vue$/, test: /\.vue$/,
use: 'vue-loader' use: 'vue-loader'
}, },
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{ {
test: /\.css$/, test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"], use: [MiniCssExtractPlugin.loader, "css-loader"],
@@ -40,8 +47,7 @@ module.exports = (env = {}) => {
plugins: [ plugins: [
new VueLoaderPlugin(), new VueLoaderPlugin(),
new BundleTracker({ new BundleTracker({
filename: 'webpack-stats.json', filename: './webpack-stats.json',
path: path.resolve(__dirname, './'),
publicPath: '/static/bundles/', publicPath: '/static/bundles/',
integrity: true integrity: true
}), }),
View File
Whitespace-only changes.
Generated
+1931
View File
File diff suppressed because it is too large. Load diff
+48 -65
View File
@@ -1,73 +1,56 @@
[project] [tool.black]
line_length = 119
[tool.poetry]
name = "cbwebreader" name = "cbwebreader"
version = "2.0.0" version = "1.1.8"
description = "CBR/Z Web Reader" description = "CBR/Z Web Reader"
readme = "README.md"
requires-python = ">=3.14"
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"
dependencies = [ [tool.poetry.dependencies]
"dj-database-url>=3.1.2", python = "^3.12"
"django>=6.1.2", Django = "^4.1"
"django-boost>=2.2", gunicorn = "^21.2.0"
"django-cors-headers>=4.9.0", dj-database-url = "^2.1.0"
"django-csp>=4.0", python-dotenv = "^1.0.0"
"django-extensions>=4.1", loguru = "^0.7.0"
"django-filter>=26.2", django-silk = "^5.0.0"
"django-imagekit>=6.1.1", mysqlclient = "^2.0.1"
"django-permissions-policy>=4.34.0", psycopg2-binary = "^2.9.6"
"django-silk>=5.6.0", rarfile = "^4.0"
"django-webpack-loader>=3.2.4", django-extensions = "^3.2.1"
"djangorestframework>=3.18.3", Pillow = "^10.0.1"
"djangorestframework-simplejwt>=5.5.1", django-imagekit = "^5.0.0"
"drf-yasg>=1.21.18", PyMuPDF = "~1.20.2"
"gunicorn>=26.2.0", django-bootstrap4 = "^23.1"
"loguru>=0.7.3", django-csp = "^3.7"
"pillow>=12.3.0", django-boost = "^2.1"
"psycopg2-binary>=2.9.13", django-sri = "^0.7.0"
"pymupdf>=1.28.2", django-permissions-policy = "^4.15.0"
"python-dotenv>=1.2.4", djangorestframework = "^3.13.1"
"rarfile>=4.5", django-filter = "^23.1"
] django-cors-headers = "^4.2.0"
djangorestframework-simplejwt = "^5.2.0"
django-webpack-loader = "^2.0.1"
drf-yasg = "^1.20.0"
drf-extensions = "^0.7.1"
[dependency-groups] [tool.poetry.dev-dependencies]
dev = [ mypy = "^1.2.0"
"coverage>=7.13.5", Werkzeug = "^2.2"
"ipython>=9.13.0", pyOpenSSL = "^22.0.0"
"mypy>=2", ipython = "^8.12.0"
"pre-commit>=4.6", coverage = "^7.2.3"
"pylint>=4.0.5", pre-commit = "^3.2.2"
"pylint-django>=2.7.0", flake8 = "^6.0.0"
"pyopenssl>=26.2.0", flake8-annotations = "^3.0.0"
"pytest>=8.4",
"pytest-django>=4.8",
"ruff>=0.17.0",
"werkzeug>=3.1.8",
]
[tool.ruff] [tool.poetry.group.dev.dependencies]
target-version = "py314" pylint = "^2.15.0"
line-length = 120 pylint-django = "^2.5.3"
extend-exclude = [ mypy = "^1.2.0"
"*/migrations/*",
"cbreader/settings",
"frontend",
]
[tool.ruff.lint] [build-system]
select = ["E", "F", "W", "I", "ANN", "B", "C4", "SIM", "UP"] requires = ["poetry-core>=1.0.0"]
# These were ignored by the previous flake8-annotations setup (ANN001 is the build-backend = "poetry.core.masonry.api"
# modern name for the old ANN101).
ignore = ["ANN001", "ANN002", "ANN003"]
[tool.ruff.lint.isort]
combine-as-imports = true
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "cbreader.settings.test"
python_files = ["test_*.py"]
filterwarnings = [
# drf_yasg still uses jsonschema.RefResolver, which is deprecated upstream.
"ignore::DeprecationWarning:swagger_spec_validator",
]
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pep8maxlinelength = 119
python_files = tests.py test_*.py *_tests.py
+46
View File
@@ -0,0 +1,46 @@
asgiref==3.7.2 ; python_version >= "3.12" and python_version < "4.0"
autopep8==2.0.4 ; python_version >= "3.12" and python_version < "4.0"
beautifulsoup4==4.12.2 ; python_version >= "3.12" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
dj-database-url==2.1.0 ; python_version >= "3.12" and python_version < "4.0"
django-appconf==1.0.5 ; python_version >= "3.12" and python_version < "4.0"
django-boost==2.1 ; python_version >= "3.12" and python_version < "4.0"
django-bootstrap4==23.2 ; python_version >= "3.12" and python_version < "4.0"
django-cors-headers==4.2.0 ; python_version >= "3.12" and python_version < "4.0"
django-csp==3.7 ; python_version >= "3.12" and python_version < "4.0"
django-extensions==3.2.3 ; python_version >= "3.12" and python_version < "4.0"
django-filter==23.3 ; python_version >= "3.12" and python_version < "4.0"
django-imagekit==5.0.0 ; python_version >= "3.12" and python_version < "4.0"
django-permissions-policy==4.17.0 ; python_version >= "3.12" and python_version < "4.0"
django-silk==5.0.4 ; python_version >= "3.12" and python_version < "4.0"
django-sri==0.7.0 ; python_version >= "3.12" and python_version < "4.0"
django-webpack-loader==2.0.1 ; python_version >= "3.12" and python_version < "4.0"
django==4.2.5 ; python_version >= "3.12" and python_version < "4.0"
djangorestframework-simplejwt==5.3.0 ; python_version >= "3.12" and python_version < "4.0"
djangorestframework==3.14.0 ; python_version >= "3.12" and python_version < "4.0"
drf-extensions==0.7.1 ; python_version >= "3.12" and python_version < "4.0"
drf-yasg==1.21.7 ; python_version >= "3.12" and python_version < "4.0"
gprof2dot==2022.7.29 ; python_version >= "3.12" and python_version < "4.0"
gunicorn==21.2.0 ; python_version >= "3.12" and python_version < "4.0"
inflection==0.5.1 ; python_version >= "3.12" and python_version < "4.0"
loguru==0.7.2 ; python_version >= "3.12" and python_version < "4.0"
mysqlclient==2.2.0 ; python_version >= "3.12" and python_version < "4.0"
packaging==23.2 ; python_version >= "3.12" and python_version < "4.0"
pilkit==3.0 ; python_version >= "3.12" and python_version < "4.0"
pillow==10.0.1 ; python_version >= "3.12" and python_version < "4.0"
psycopg2-binary==2.9.9 ; python_version >= "3.12" and python_version < "4.0"
pycodestyle==2.11.0 ; python_version >= "3.12" and python_version < "4.0"
pyjwt==2.8.0 ; python_version >= "3.12" and python_version < "4.0"
pymupdf==1.20.2 ; python_version >= "3.12" and python_version < "4.0"
python-dotenv==1.0.0 ; python_version >= "3.12" and python_version < "4.0"
pytz==2023.3.post1 ; python_version >= "3.12" and python_version < "4.0"
pyyaml==6.0.1 ; python_version >= "3.12" and python_version < "4.0"
rarfile==4.1 ; python_version >= "3.12" and python_version < "4.0"
soupsieve==2.5 ; python_version >= "3.12" and python_version < "4.0"
sqlparse==0.4.4 ; python_version >= "3.12" and python_version < "4.0"
typing-extensions==4.8.0 ; python_version >= "3.12" and python_version < "4.0"
tzdata==2023.3 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
ua-parser==0.18.0 ; python_version >= "3.12" and python_version < "4.0"
uritemplate==4.1.1 ; python_version >= "3.12" and python_version < "4.0"
user-agents==2.2.0 ; python_version >= "3.12" and python_version < "4.0"
win32-setctime==1.1.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
+26
View File
@@ -0,0 +1,26 @@
[tool:pytest]
addopts = --flake8
[flake8]
max-line-length = 120
ignore =
* ANN101
* ANN002
* ANN003
# Ignore rules which contradicts black's formatting choices:
; * E501
; * W503
; * W504
; * E266
exclude =
# Exclude these files
*/migrations/*
cbreader/settings
frontend
[tool:isort]
line_length = 119
indent = ' '
multi_line_output = 3
include_trailing_comma = 1
+12
View File
@@ -0,0 +1,12 @@
from distutils.core import setup
setup(
name="cbwebreader",
version="",
packages=["comic", "comic.migrations", "cbreader", "comic_auth", "comic_auth.migrations"],
url="https://github.com/ajurna/cbwebreader",
license="http://creativecommons.org/licenses/by-sa/4.0/",
author="Ajurna",
author_email="ajurna@gmail.com",
description="Comic Book Web Reader",
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Whitespace-only changes.
Generated
-1506
View File
File diff suppressed because it is too large. Load diff