Compare commits

..
Author SHA1 Message Date
dependabot[bot] 24c12e19be Bump pyjwt from 2.10.1 to 2.15.0
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.10.1 to 2.15.0.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.10.1...2.15.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 08:33:57 +00:00
ajurna 80566e657e **Add continuous reading mode and enhance accessibility in TheComicReader**
- Introduced a continuous reading mode ("scroll"), allowing users to read without pagination.
- Implemented a toggle in the UI to switch between "paged" and "continuous" modes, with preferences saved in `localStorage`.
- Enhanced keyboard navigation by throttling repeated actions and ignoring irrelevant keypresses during form interactions.
- Integrated `IntersectionObserver` to handle lazy-loading of images and dynamically extending rendered content in continuous mode.
- Updated tests to validate both reading modes, ensuring proper state management and observer handling.
- Adjusted animations and transitions for smoother visual feedback in both modes, respecting users' motion-reduction preferences.
2026-10-16 09:30:46 +01:00
ajurna 494b64621e Bump version to 2.0.0 for major release. 2026-10-14 13:21:51 +01:00
ajurna a9e9bfb480 **Improve database handling and disable redundant health checks**
- Added logic to construct `DATABASE_URL` from `DB_*` variables to prevent accidental fallback to SQLite during Docker Compose deployments.
- Updated `DATABASES` configuration to use `dj_database_url.parse` instead of deprecated `config`.
- Disabled health checks for services that do not expose a web port to avoid unnecessary probes.
- Removed unused `libmariadb-dev` and `pkg-config` dependencies from the `Dockerfile`.
2026-10-14 12:32:11 +01:00
ajurna 77a80dd322 **Relocate Nginx configuration to the compose directory**
- Moved `nginx.conf` from `cbreader/settings/` to `compose/` for better organization of deployment-specific files.
- Updated `docker-compose.yml` to reference the new path for Nginx configuration.
2026-10-14 12:11:07 +01:00
ajurna cc557543c5 **Add Vitest support and migrate frontend tests**
- Added Vitest configuration (`vitest.config.mjs`) for frontend unit testing with a `jsdom` environment.
- Migrated and rewrote key frontend tests (`api.test.js`, `comiclist.test.js`, `comicreader.test.js`, `historytable.test.js`, `router.test.js`, `store.test.js`) to Vitest, improving maintainability.
- Removed `tsconfig.json` and related TypeScript loaders from `webpack` configurations (`webpack.dev.js`, `webpack.prod.js`).
- Adjusted `eslint.config.mjs` to support Vitest tests.
- Updated `package-lock.json` to include Vitest, test utilities (`jsdom`, `@vue/test-utils`), and other associated packages.
- Updated Nginx configuration for better compression on assets (`gzip` adjustments).
- Enhanced `entrypoint.sh` to include first-run library indexing (`--if-empty` argument).
- Simplified the `scan_comics` command with a dedicated test for empty database scans.

This transition improves testing workflows and backend/frontend synergy while removing unused TypeScript dependencies.
2026-10-14 10:07:14 +01:00
ajurna ce809ed1a9 **Refactor directory scanning and add scan task support**
- Added a new `scan_directory` task to enable folder scans as asynchronous tasks while maintaining compatibility with the immediate backend.
- Introduced the `reconcile` function, replacing `generate_directory` for improved separation of concerns between database writes and read operations.
- Established a unique constraint on `Directory` (`one_directory_name_per_parent`) to prevent duplicate directories under the same parent.
- Added a "Scan" button to the frontend for re-reading folders from disk, along with new task invocation endpoints in the API.
- Updated tests across modules to validate the new `scan_directory` task, unique constraints, and database indexing logic.
- Improved cleanup logic for stale files, directories, and thumbnails to handle concurrent operations efficiently.
- Simplified `scan_comics` management command by removing user dependency and aligning it with the new `reconcile` function.
- Enhanced frontend components (`TheComicReader`, `TheComicList`, and others) to include lifecycle method cleanups, prevent memory leaks, and handle task-based scans.
2026-10-13 15:58:06 +01:00
ajurna 4d0ba750ad Upgrade frontend dependencies and switch ESLint to a flat config
- Runtime majors: vue-router 5, reveal.js 6, fontawesome 7. Checked against the
  code that uses them: no change needed for reveal.js (the default export is
  still a callable factory and every method/option used is still present) or
  fontawesome (all seven registered icons still resolve).
- frontend/src/router/index.js: guards return the redirect instead of calling the
  deprecated next() callback, which works on vue-router 4 and 5.
- Add frontend/eslint.config.mjs because eslint 10 requires a flat config; the
  lint script now runs eslint directly instead of vue-cli-service.
- Drop tooling nothing in the webpack configs uses (@vue/cli-*, jshint,
  html-webpack-plugin, webpack-bundle-analyzer, @babel/core) and delete the dead
  vue.config.js. npm audit 35 -> 5 (all dev-only), --omit=dev is 0.
- frontend/src/views/UserView.vue: the $route watcher no longer declares unused
  params.
2026-10-13 15:57:40 +01:00
ajurna 6bcdf89fdc Remove unused files and dependencies
- Delete empty modules (comic/views.py, comic/urls.py, comic/forms.py), the
  empty mypy.ini, .readthedocs.yaml (no docs/ directory, pins Python 3.8) and
  setup.py (distutils is gone in Python 3.12+ and it still listed the deleted
  comic_auth; packaging is handled by pyproject.toml).
- Untrack the base64 PDF blob 'data' and the manual-only test_comics fixtures;
  the suite builds archives in tmp_path via comic/tests/factories.py. UnRAR.exe
  stays on disk for manual rarfile testing but is not in git.
- Drop unused dependencies: django-bootstrap4, django-sri, drf-extensions,
  mysqlclient, setuptools.
- build-test.ps1 no longer uses poetry.
- Ignore .venv/, .mypy_cache/, .ruff_cache/.
2026-10-13 15:57:40 +01:00
ajurna 79c0647cc4 Implement cookie-based JWT authentication
- Introduced `CookieJWTAuthentication` for authentication via HttpOnly cookies, enabling browser-initiated requests without `Authorization` headers.
- Added authentication views (`CookieTokenObtainPairView`, `CookieTokenRefreshView`, `CookieTokenBlacklistView`) to replace default SimpleJWT views.
- Updated Vuex store and frontend logic to handle sessions using HttpOnly cookies instead of localStorage.
- Enhanced backend and frontend configurations (Django settings, router, Vuex, and API service) to fully support cookie-based authentication.
- Updated authentication tests to ensure correct cookie handling and authentication flows.
2026-10-13 13:38:48 +01:00
ajurna a7e9bd0743 **Replace Flake8 with Ruff, refactor type hints, and enhance archive processing logic**
- Removed `flake8` and `flake8-annotations` from dependencies, replaced by `ruff` for linting.
- Introduced cleaner type hinting and modern Python annotations (PEP 604 style) across multiple modules.
- Moved archive processing to a dedicated helper (`comic.archives`) for improved maintainability.
- Updated and simplified logic for archive file retrieval, directory cleaning, and user access validation.
- Improved error handling and added support for user classification restrictions in API responses.
2026-10-13 12:07:47 +01:00
ajurna 1148f4072b **Replace Flake8 with Ruff, refactor type hints, and enhance archive processing logic**
- Removed `flake8` and `flake8-annotations` from dependencies, replaced by `ruff` for linting.
- Introduced cleaner type hinting and modern Python annotations (PEP 604 style) across multiple modules.
- Moved archive processing to a dedicated helper (`comic.archives`) for improved maintainability.
- Updated and simplified logic for archive file retrieval, directory cleaning, and user access validation.
- Improved error handling and added support for user classification restrictions in API responses.
2026-10-13 11:36:28 +01:00
ajurna 147a3a54ef **Refactor Docker and Compose for enhanced compatibility and efficiency**
- Restructured `Dockerfile` to include a dedicated frontend build stage and improved runtime configurations.
- Added new health checks and adjusted environment variables for better container management.
- Updated cron job configurations to leverage `/opt/venv` and align with new paths.
- Refined `docker-compose` files for multi-service setups, including better volume and environment variable handling.
- Migrated `cbwebreader-cron` service setup and improved restart policies across the board.
- Enhanced `.gitea` CI workflow to use updated tools (`uv`), streamline buildx processes, and ensure proper version extraction from metadata.
2026-10-13 10:25:07 +01:00
ajurna 9b9db755e6 Handle unreadable archives and adjust cron job configuration
- Skip page count assignment for corrupt or incompatible archives in comic processing.
- Update `Dockerfile` to append crontab entries to `/etc/crontab` instead of `/etc/cron.daily/cbreader`.
- Modify crontab entry to use the virtual environment's Python binary and include the `root` user for execution.
2026-10-13 09:46:26 +01:00
ajurna 1bbe0071a0 **Update dependencies to their latest versions**
Upgrade various Python package dependencies in `uv.lock`. Includes updates to critical packages such as Django (6.0.5 → 6.1.2), DRF (3.17.1 → 3.18.3), and others. Also adds new packages: `jsonschema`, `jsonschema-specifications`, and `importlib-resources`.
2026-10-13 09:43:09 +01:00
ajurna 91729f91dc Improve type hinting for ComicBook in generate_thumbnail method 2026-06-02 15:41:15 +01:00
ajurna 67b2d9756a updating deps 2026-05-11 13:53:20 +01:00
ajurna 3ac2f8698c updating deps 2026-05-11 13:30:56 +01:00
ajurna 9aaa0f1c9f updating deps
Build and push image / deploy (push) Has been cancelled
2026-01-15 09:56:39 +00:00
ajurna 65dbe8e18d Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.pre-commit-config.yaml
#	.python-version
#	Dockerfile
#	build.ps1
#	comic/migrations/0008_auto_20160331_1140.py
#	entrypoint.sh
#	frontend/package-lock.json
#	pyproject.toml
#	requirements.txt
#	uv.lock
2026-01-14 17:01:52 +00:00
ajurna 08a3c74a31 updating deps 2026-01-14 16:59:53 +00:00
ajurna f7c698cd64 updating deps 2024-05-03 15:45:47 +01:00
ajurna 9e514354c8 updating deps 2024-05-03 15:45:36 +01:00
84 changed files with 8062 additions and 10132 deletions

No files matched your search

+1 -4
View File
@@ -3,17 +3,14 @@
!cbreader
!comic
!comic_auth
!static
!manage.py
!pyproject.toml
!setup.cfg
!uv.lock
!entrypoint.sh
!entrypoint-cron.sh
!requirements.txt
!package-lock.json
!package.json
!frontend
!uv.lock
/frontend/node_modules
/frontend/dist
+4 -2
View File
@@ -15,8 +15,10 @@ DB_DATABASE=cbwebreader
# https://github.com/jacobian/dj-database-url
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}
#Path to your comics.
COMIC_BOOK_VOLUME=/media/plex/comics
# Path to your comics *inside* the container. Keep this as a container path.
COMIC_BOOK_VOLUME=/comics
# Host path that is mounted to the path above.
COMIC_BOOK_HOST_PATH=/media/plex/comics
STATIC_ROOT='/static'
+4 -2
View File
@@ -15,8 +15,10 @@ DB_DATABASE=cbwebreader
# https://github.com/jacobian/dj-database-url
DATABASE_URL='postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}'
# Path to your comics.
COMIC_BOOK_VOLUME=/path/to/comic/folder
# Path to your comics *inside* the container. Keep this as a container path.
COMIC_BOOK_VOLUME=/comics
# 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.
STATIC_ROOT=/static
+8 -12
View File
@@ -12,25 +12,21 @@ jobs:
run: curl -fsSL https://get.docker.com | sh
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
- name: Set up Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: install pip
run: apt update && apt install -y python3-pip
- uses: abatilo/actions-poetry@v2
with:
poetry-version: "1.6.1"
- uses: actions/setup-node@v3
- uses: actions/checkout@v3
# Extract version from Poetry
- uses: actions/checkout@v4
- name: Install uv
run: pip install uv
# Extract version from the project metadata
- name: Get version
run: echo "POETRY_VERSION=$(poetry version --short)" >> $GITHUB_ENV
run: echo "PROJECT_VERSION=$(uv version --short)" >> $GITHUB_ENV
- name: Build and push Docker image
run: |
docker build -t ajurna/cbwebreader:latest -t ajurna/cbwebreader:${{ env.POETRY_VERSION }} .
docker push ajurna/cbwebreader:${{ env.POETRY_VERSION }}
docker build -t ajurna/cbwebreader:latest -t ajurna/cbwebreader:${{ env.PROJECT_VERSION }} .
docker push ajurna/cbwebreader:${{ env.PROJECT_VERSION }}
docker push ajurna/cbwebreader:latest
+17
View File
@@ -96,3 +96,20 @@ node_modules
localhost-key.pem
localhost.pem
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
+5 -7
View File
@@ -2,16 +2,14 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/pycqa/flake8
rev: "5.0.4"
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.0
hooks:
- id: flake8
additional_dependencies: [
'flake8-annotations==2.9.1',
]
- id: ruff
args: [ --fix ]
+1 -1
View File
@@ -1,6 +1,6 @@
[MAIN]
max-line-length=120
ignore-paths=.*/migrations
ignore-paths=comic/migrations
load-plugins = pylint_django
disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring
good-names=pk
+1 -1
View File
@@ -1 +1 @@
3.13
3.14
-8
View File
@@ -1,8 +0,0 @@
# File: .readthedocs.yaml
version: 2
# Explicitly set the version of Python and its requirements
python:
version: 3.8
install:
- requirements: docs/requirements.txt
+42 -27
View File
@@ -1,42 +1,57 @@
FROM python:3.13-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 \
PYTHONHASHSEED=random \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
PYTHONDONTWRITEBYTECODE=1
RUN mkdir /src
RUN mkdir /static
ENV UV_PROJECT_ENVIRONMENT=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 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
# 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 pyproject.toml /src
COPY uv.lock /src
COPY --from=frontend /src/dist /src/frontend/dist
COPY --from=frontend /src/webpack-stats.json /src/frontend/webpack-stats.json
RUN echo "deb http://ftp.uk.debian.org/debian bookworm non-free non-free-firmware" > /etc/apt/sources.list.d/non-free.list
RUN mkdir -p /static /media
RUN apt update \
&& apt install -y software-properties-common \
&& apt-add-repository non-free \
&& apt update \
&& apt install -y npm cron unrar libmariadb-dev libpq-dev pkg-config \
&& uv sync --frozen \
&& 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
RUN cat /src/cbreader/crontab >> /etc/crontab
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"]
+1 -2
View File
@@ -1,4 +1,3 @@
poetry export --without-hashes -f requirements.txt --output requirements.txt
$version=poetry version -s
$version=uv version --short
docker build . -t ajurna/cbwebreader:beta --no-cache
# docker push ajurna/cbwebreader:beta
+1 -1
View File
@@ -1,3 +1,3 @@
$version=uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version
$version=uv version --short
docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
docker push ajurna/cbwebreader --all-tags
+3 -1
View File
@@ -1 +1,3 @@
*/15 * * * * python /src/manage.py scan_comics --settings=cbreader.settings.base
# Cron captures job output for e-mail, which is discarded without an MTA, so write to
# 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
+39 -3
View File
@@ -7,6 +7,7 @@ import os
from datetime import timedelta
from pathlib import Path
from typing import Dict, List
from urllib.parse import quote
import dj_database_url
from dotenv import load_dotenv
@@ -37,12 +38,10 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
'drf_yasg',
'webpack_loader',
'bootstrap4',
"comic",
'django_extensions',
'imagekit',
'django_boost',
'sri',
"corsheaders",
'django_filters',
'rest_framework',
@@ -74,8 +73,19 @@ WSGI_APPLICATION = "cbreader.wsgi.application"
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:
DATABASES = {"default": dj_database_url.config(conn_max_age=500)}
DATABASES = {"default": dj_database_url.parse(DATABASE_URL, conn_max_age=500)}
else:
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3")}}
@@ -231,11 +241,24 @@ REST_FRAMEWORK = {
],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'comic.auth.CookieJWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
)
}
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 = {
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
@@ -247,6 +270,19 @@ SIMPLE_JWT = {
'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')
TEMPLATES = [
-23
View File
@@ -1,23 +0,0 @@
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
@@ -0,0 +1,24 @@
"""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'
+6 -7
View File
@@ -23,10 +23,9 @@ from drf_yasg import openapi
from drf_yasg.views import get_schema_view
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, TokenBlacklistView
from comic import rest, feeds
# from rest_framework_extensions.routers import ExtendedDefaultRouter
from comic import auth_views, feeds, rest
schema_view = get_schema_view(
openapi.Info(
@@ -57,12 +56,12 @@ router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup')
urlpatterns = [
path('admin/', admin.site.urls),
path("feed/<user_selector>/", feeds.RecentComicsAPI()),
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger\.(?P<format>json|yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/token/blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'),
path('api/token/', auth_views.CookieTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', auth_views.CookieTokenRefreshView.as_view(), name='token_refresh'),
path('api/token/blacklist/', auth_views.CookieTokenBlacklistView.as_view(), name='token_blacklist'),
path('api/', include(router.urls)),
path("",
TemplateView.as_view(template_name="application.html"),
+1 -2
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
from django.contrib import admin
from .models import Directory, ComicBook, ComicStatus, UserMisc
from .models import ComicBook, ComicStatus, Directory, UserMisc
@admin.register(Directory)
+30
View File
@@ -0,0 +1,30 @@
"""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
@@ -0,0 +1,66 @@
"""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
@@ -0,0 +1,72 @@
"""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.syndication.views import Feed
from django.db.models import Case, When, PositiveSmallIntegerField, F, QuerySet
from django.db.models import Case, F, PositiveSmallIntegerField, QuerySet, When
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from comic.models import ComicBook, UserMisc, Directory
from comic.models import ComicBook, Directory, UserMisc
class RecentComicsAPI(Feed):
View File
Whitespace-only changes.
+21 -13
View File
@@ -1,12 +1,11 @@
from typing import Optional
from pathlib import Path
from django.contrib.auth import get_user_model
from django.contrib.auth.base_user import AbstractBaseUser
from django.conf import settings
from django.core.management.base import BaseCommand, CommandParser
from loguru import logger
from comic.models import Directory
from comic.processing import generate_directory
from comic.models import ComicBook, Directory
from comic.processing import reconcile
class Command(BaseCommand):
@@ -22,16 +21,25 @@ class Command(BaseCommand):
action='store_true',
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:
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()
def scan_directory(self, user: Optional[AbstractBaseUser] = None, directory: Optional[Directory] = None) -> None:
if not user:
user_model = get_user_model()
user: AbstractBaseUser = user_model.objects.first()
for item in generate_directory(user, directory):
if item is Directory:
logger.info(item)
self.scan_directory(user, item)
def scan_directory(self, directory: Directory | None = None) -> None:
reconcile(directory)
path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
if self.OUTPUT:
self.stdout.write(str(path))
else:
logger.info(path)
for child in Directory.objects.filter(parent=directory):
self.scan_directory(child)
@@ -0,0 +1,38 @@
# 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'),
),
]
+21 -24
View File
@@ -1,16 +1,13 @@
import io
import mimetypes
import uuid
import zipfile
from functools import reduce
from pathlib import Path
from typing import Optional, List, Union, Tuple, Final, IO
from typing import IO, Final
# noinspection PyPackageRequirements
import pymupdf
import rarfile
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import InMemoryUploadedFile
@@ -19,7 +16,10 @@ from django.db.models import UniqueConstraint
from django_boost.models.fields import AutoOneToOneField
from imagekit.models import ProcessedImageField
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
if settings.UNRAR_TOOL:
@@ -50,6 +50,9 @@ class Directory(models.Model):
class Meta:
ordering = ['name']
constraints = [
models.UniqueConstraint(fields=['parent', 'name'], name='one_directory_name_per_parent'),
]
def __str__(self) -> str:
@@ -64,7 +67,7 @@ class Directory(models.Model):
return 'Directory'
def generate_thumbnail(self) -> None:
book: ComicBook = ComicBook.objects.filter(directory=self).order_by('file_name').first()
book: ComicBook|None = ComicBook.objects.filter(directory=self).order_by('file_name').first()
if not book:
return
if not book.thumbnail:
@@ -84,7 +87,7 @@ class Directory(models.Model):
return reduce(lambda x, y: Path(x, y), path_items)
return Path(path_items[0])
def get_path_items(self, path_items: Optional[List] = None) -> List[Path]:
def get_path_items(self, path_items: list | None = None) -> list[Path]:
if path_items is None:
path_items = []
path_items.append(self.name)
@@ -92,7 +95,7 @@ class Directory(models.Model):
self.parent.get_path_items(path_items)
return path_items
def get_path_objects(self, path_items: Optional[List] = None) -> List["Directory"]:
def get_path_objects(self, path_items: list | None = None) -> list[Directory]:
if path_items is None:
path_items = []
path_items.append(self)
@@ -141,7 +144,7 @@ class ComicBook(models.Model):
return Path(base_dir, self.directory.get_path(), self.file_name)
return Path(base_dir, self.file_name)
def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]:
def get_image(self, page: int) -> tuple[IO[bytes], str] | tuple[bool, bool]:
if self.file_name.lower().endswith('.pdf'):
# noinspection PyUnresolvedReferences
doc = pymupdf.open(self.get_pdf())
@@ -168,15 +171,16 @@ class ComicBook(models.Model):
except zipfile.BadZipfile:
return False, False
file_name, file_mime = self.get_archive_files(archive)[page]
file_name, file_mime = get_archive_files(archive)[page]
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)
return img, pil_data, 'Image/JPEG'
def generate_thumbnail_archive(self, page_index: int = 0) -> Union[Tuple[io.BytesIO, Image_type, str],
Tuple[bool, bool, bool]]:
def generate_thumbnail_archive(
self, page_index: int = 0
) -> tuple[io.BytesIO, Image_type, str] | tuple[bool, bool, bool]:
img, content_type, pil_data = False, False, False
if page_index:
img, content_type = self.get_image(page_index)
@@ -209,7 +213,7 @@ class ComicBook(models.Model):
)
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())
page = doc[page_index]
pix = page.get_pixmap()
@@ -222,7 +226,7 @@ class ComicBook(models.Model):
return img, pil_data
@staticmethod
def process_comic_book(comic_file_path: Path, directory: "Directory" = False) -> Union["ComicBook", Path]:
def process_comic_book(comic_file_path: Path, directory: Directory = False) -> ComicBook | Path:
try:
book = ComicBook.objects.get(file_name=comic_file_path.name, version=0)
book.directory = directory
@@ -240,7 +244,7 @@ class ComicBook(models.Model):
return comic_file_path
if archive_type == 'archive':
book.page_count = len(book.get_archive_files(archive))
book.page_count = len(get_archive_files(archive))
elif archive_type == 'pdf':
book.page_count = archive.page_count
@@ -252,7 +256,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.file_name)
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, pymupdf.Document], str]:
def get_archive(self) -> tuple[rarfile.RarFile | zipfile.ZipFile | pymupdf.Document, str]:
archive_path = self.get_archive_path
try:
return rarfile.RarFile(archive_path), 'archive'
@@ -273,17 +277,10 @@ class ComicBook(models.Model):
def get_page_count(self) -> int:
archive, archive_type = self.get_archive()
if archive_type == 'archive':
return len(self.get_archive_files(archive))
return len(get_archive_files(archive))
elif archive_type == 'pdf':
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):
user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE)
+58 -59
View File
@@ -1,49 +1,59 @@
import mimetypes
import zipfile
from itertools import chain
from collections.abc import Iterable
from pathlib import Path
from typing import NamedTuple, List, Optional, Union
import rarfile
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.db.models import Count, Q, F, Case, When, PositiveSmallIntegerField, QuerySet, ExpressionWrapper, \
IntegerField
from django.db.models import (
BooleanField,
Case,
Count,
ExpressionWrapper,
F,
IntegerField,
PositiveSmallIntegerField,
Q,
QuerySet,
Value,
When,
)
from django.db.models.functions import Coalesce
from comic import models
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive
def generate_directory(user: AbstractBaseUser, directory: Optional[models.Directory] = None) \
-> List[Union[models.Directory, models.ComicBook]]:
def reconcile(directory: models.Directory | None = None) -> None:
"""Bring the database in line with the filesystem for one directory.
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
files = []
dir_db_query = models.Directory.objects.filter(parent=directory)
clean_directories(dir_db_query, dir_path, directory)
clean_directories(models.Directory.objects.filter(parent=directory), dir_path, directory)
clean_files(models.ComicBook.objects.filter(directory=directory), 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)
dir_db_query = dir_db_query.annotate(
def list_directory(user: AbstractBaseUser, directory: models.Directory | None = None) \
-> 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),
progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True,
comicbook__comicstatus__user=user), distinct=True),
finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()),
unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()),
)
files.extend(dir_db_query)
# Create Missing Status
new_status = [models.ComicStatus(comic=file, user=user) for file in
file_db_query.exclude(comicstatus__in=models.ComicStatus.objects.filter(
comic__in=file_db_query, user=user))]
models.ComicStatus.objects.bulk_create(new_status)
file_db_query = file_db_query.annotate(
progress=F('comicstatus__last_read_page') + 1,
finished=F('comicstatus__finished'),
unread=F('comicstatus__unread'),
# A book with no status for this user is simply unread, so the annotations have to
# stand in for the missing row instead of assuming one exists.
file_db_query = models.ComicBook.objects.filter(directory=directory).annotate(
progress=Coalesce(F('comicstatus__last_read_page') + 1, Value(0, output_field=IntegerField())),
finished=Coalesce(F('comicstatus__finished'), Value(False, output_field=BooleanField())),
unread=Coalesce(F('comicstatus__unread'), Value(True, output_field=BooleanField())),
user=F('comicstatus__user'),
classification=Case(
When(directory__isnull=True, then=models.Directory.Classification.C_G),
@@ -52,68 +62,57 @@ def generate_directory(user: AbstractBaseUser, directory: Optional[models.Direct
)
).filter(Q(user__isnull=True) | Q(user=user.id))
files.extend(file_db_query)
for file in chain(file_db_query, dir_db_query):
if file.thumbnail and not Path(file.thumbnail.path).exists():
file.thumbnail.delete()
file.save()
files = list(dir_db_query) + list(file_db_query)
files.sort(key=lambda x: x.title)
files.sort(key=lambda x: x.type, reverse=True)
return files
def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional[models.Directory] = None) -> None:
dir_db_set = set(Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories)
dir_list = set(x for x in sorted(dir_path.glob('*')) if x.is_dir())
def clean_directories(directories: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
dir_db_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()}
# Create new directories db instances
for new_directory in dir_list - dir_db_set:
models.Directory(name=new_directory.name, parent=directory).save()
# get_or_create, not save(): another request may have inserted the same directory
# 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
for stale_directory in dir_db_set - dir_list:
models.Directory.objects.get(name=stale_directory.name, parent=directory).delete()
models.Directory.objects.filter(name=stale_directory.name, parent=directory).delete()
def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: Optional[models.Directory] = None) \
-> None:
file_list = set(x for x in sorted(dir_path.glob('*')) if x.is_file())
files_db_set = set(Path(dir_path, x.file_name) for x in files)
def clean_files(files: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
file_list = {x for x in sorted(dir_path.glob('*')) if x.is_file()}
files_db_set = {Path(dir_path, x.file_name) for x in files}
# Parse new comics
books_to_add = []
for new_comic in file_list - files_db_set:
if new_comic.suffix.lower() in settings.SUPPORTED_FILES:
new_book = models.ComicBook(file_name=new_comic.name, directory=directory)
archive, archive_type = new_book.get_archive()
try:
archive, archive_type = new_book.get_archive()
if archive_type == 'archive':
new_book.page_count = len(get_archive_files(archive))
elif archive_type == 'pdf':
new_book.page_count = archive.page_count
except NotCompatibleArchive:
# Unreadable/corrupt archive - index the file with no page count
pass
books_to_add.append(new_book)
models.ComicBook.objects.bulk_create(books_to_add)
status_to_add = []
for book in books_to_add:
status_to_add.append(models.ComicStatus(user=user, comic=book))
models.ComicStatus.objects.bulk_create(status_to_add)
# ignore_conflicts: a concurrent scan may have indexed the same file, and
# (directory, file_name) is unique.
models.ComicBook.objects.bulk_create(books_to_add, ignore_conflicts=True)
# Remove stale comic instances
for stale_comic in files_db_set - file_list:
models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete()
models.ComicBook.objects.filter(file_name=stale_comic.name, directory=directory).delete()
class ArchiveFile(NamedTuple):
file_name: str
mime_type: str
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]
]
def clean_thumbnails(items: Iterable[models.Directory | models.ComicBook]) -> None:
for item in items:
if item.thumbnail and not Path(item.thumbnail.path).exists():
item.thumbnail.delete()
item.save()
+126 -91
View File
@@ -1,27 +1,39 @@
import json
from collections.abc import Iterable
from http.client import HTTPResponse
from pathlib import Path
from typing import Union, Optional, Dict, Iterable, List
from uuid import UUID
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db.models import Case, When, F, PositiveSmallIntegerField, FileField, QuerySet
from django.db.models import Case, F, FileField, PositiveSmallIntegerField, QuerySet, When
from django.http import FileResponse
from django.utils.crypto import get_random_string
from drf_yasg.utils import swagger_auto_schema
from rest_framework import viewsets, serializers, mixins, permissions, status, renderers
from rest_framework import mixins, permissions, renderers, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request
from rest_framework.response import Response
from comic import models
from comic.processing import generate_directory
from comic import models, processing, tasks
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):
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
source='usermisc')
@@ -60,16 +72,15 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
"""
target_user = get_object_or_404(User, id=pk)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
if target_user.username == serializer.data['username']:
password = User.objects.make_random_password()
target_user.set_password(password)
target_user.save()
resp_serializer = self.get_serializer({
'username': target_user.username,
'password': password
})
return Response(resp_serializer.data)
if serializer.is_valid() and target_user.username == serializer.data['username']:
password = get_random_string(12)
target_user.set_password(password)
target_user.save()
resp_serializer = self.get_serializer({
'username': target_user.username,
'password': password
})
return Response(resp_serializer.data)
return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST)
@@ -88,7 +99,7 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
class BrowseFileField(serializers.FileField):
def to_representation(self, value: Optional[FileField]) -> Optional[str]:
def to_representation(self, value: FileField | None) -> str | None:
if not value:
return None
return Path(settings.MEDIA_URL, value.name).as_posix()
@@ -112,6 +123,10 @@ class BreadcrumbSerializer(serializers.Serializer):
name = serializers.CharField()
class ScanSerializer(serializers.Serializer):
selector = serializers.UUIDField(required=False, allow_null=True)
class BrowseViewSet(viewsets.GenericViewSet):
serializer_class = BrowseSerializer
permission_classes = [permissions.IsAuthenticated]
@@ -121,25 +136,34 @@ class BrowseViewSet(viewsets.GenericViewSet):
return
def list(self, request: Request) -> Response:
serializer = self.get_serializer(generate_directory(request.user), many=True)
serializer = self.get_serializer(processing.list_directory(request.user), many=True)
return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
def retrieve(self, request: Request, selector: UUID) -> Response:
directory = models.Directory.objects.get(selector=selector)
serializer = self.get_serializer(generate_directory(request.user, directory), many=True)
directory = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(processing.list_directory(request.user, directory), many=True)
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)})
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
queryset = []
comic = False
try:
directory = models.Directory.objects.get(selector=selector)
except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector)
directory = comic.directory
comic = models.ComicBook.objects.filter(selector=selector).first()
directory = comic.directory if comic else get_object_or_404(models.Directory, selector=selector)
for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
queryset.append({
@@ -164,25 +188,17 @@ class GenerateThumbnailViewSet(viewsets.ViewSet):
@swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()})
def retrieve(self, _request: Request, selector: UUID) -> Response:
try:
directory = models.Directory.objects.get(selector=selector)
if not directory.thumbnail:
directory.generate_thumbnail()
return Response(
self.serializer_class({
"selector": directory.selector,
"thumbnail": directory.thumbnail
}).data
)
target = models.Directory.objects.get(selector=selector)
except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector)
if not comic.thumbnail:
comic.generate_thumbnail()
return Response(
self.serializer_class({
"selector": comic.selector,
"thumbnail": comic.thumbnail
}).data
)
target = get_object_or_404(models.ComicBook, selector=selector)
if not target.thumbnail:
target.generate_thumbnail()
return Response(
self.serializer_class({
"selector": target.selector,
"thumbnail": target.thumbnail
}).data
)
class DirectionSerializer(serializers.Serializer):
@@ -212,9 +228,12 @@ class ReadViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated]
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:
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)
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'))
@@ -246,22 +265,22 @@ class ReadViewSet(viewsets.GenericViewSet):
return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
status.HTTP_403_FORBIDDEN: 'User below classification allowed',
status.HTTP_404_NOT_FOUND: 'Comic file not found'})
@action(methods=['get'], detail=True)
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response, HTTPResponse]:
book = models.ComicBook.objects.get(selector=selector)
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
try:
if book.directory.classification > misc.allowed_to_read:
return Response(status=400, data={'errors': 'Not allowed to read.'})
except AttributeError:
pass
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
def pdf(self, request: Request, selector: UUID) -> FileResponse | Response | HTTPResponse:
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.'})
pdf_path = book.get_pdf()
if not pdf_path.is_file():
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Comic file not found.'})
return FileResponse(open(pdf_path, 'rb'), content_type='application/pdf')
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
@action(methods=['get'], detail=True)
def type(self, _request: Request, selector: UUID) -> Response:
book = models.ComicBook.objects.get(selector=selector)
book = get_object_or_404(models.ComicBook, selector=selector)
return Response({'type': book.file_name.split('.')[-1].lower()})
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer)
@@ -271,15 +290,11 @@ class ReadViewSet(viewsets.GenericViewSet):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
comic_status, _ = models.ComicStatus.objects.annotate(page_count=F('comic__page_count')) \
.get_or_create(comic_id=selector, user=request.user)
comic = get_object_or_404(models.ComicBook, selector=selector)
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
comic_status.last_read_page = serializer.data['page']
comic_status.unread = False
if comic_status.page_count - 1 == comic_status.last_read_page:
comic_status.finished = True
else:
comic_status.finished = False
comic_status.finished = comic.page_count > 0 and comic_status.last_read_page >= comic.page_count - 1
comic_status.save()
return Response(self.get_serializer(comic_status).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -292,20 +307,30 @@ class PassthroughRenderer(renderers.BaseRenderer): # pylint: disable=too-few-pu
media_type = '*/*'
format = ''
def render(self, data: bytes, accepted_media_type: Optional[str] = None, renderer_context: Optional[str] = None) \
def render(self, data: bytes, accepted_media_type: str | None = None, renderer_context: str | None = None) \
-> bytes:
return data
if isinstance(data, (bytes, bytearray)):
return data
# Error responses still need to be serialised.
return json.dumps(data).encode()
class ImageViewSet(viewsets.ViewSet):
queryset = models.ComicBook.objects.all()
lookup_field = 'page'
renderer_classes = [PassthroughRenderer]
renderer_classes = [PassthroughRenderer, renderers.JSONRenderer]
permission_classes = [permissions.IsAuthenticated]
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"})
def retrieve(self, _request: Request, selector: UUID, page: int) -> FileResponse:
book = models.ComicBook.objects.get(selector=selector)
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response",
status.HTTP_403_FORBIDDEN: 'User below classification allowed',
status.HTTP_404_NOT_FOUND: 'Comic or page not found'})
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)
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
return FileResponse(img, content_type=content)
@@ -433,7 +458,7 @@ class ActionViewSet(viewsets.GenericViewSet):
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
obj.unread = False
obj.finished = True
obj.last_read_page = comic.total_pages
obj.last_read_page = max(comic.page_count - 1, 0)
status_to_update.append(obj)
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
return Response({'status': 'marked_read'})
@@ -465,7 +490,7 @@ class ActionViewSet(viewsets.GenericViewSet):
return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
def get_comics(self, selectors: Iterable[Union[str, UUID]]) -> List[str]:
def get_comics(self, selectors: Iterable[str | UUID]) -> list[str]:
data = set()
data = data.union(
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
@@ -507,10 +532,10 @@ class PasswordResetSerializer(serializers.Serializer):
try:
validate_password(data)
except ValidationError as err:
raise serializers.ValidationError(err)
raise serializers.ValidationError(err) from err
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)
if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError('New passwords do not match')
@@ -581,37 +606,47 @@ class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
permission_classes = [permissions.IsAdminUser]
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)})
def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ
"""
This will set the classification of a directory and all it's children.
This will set the classification of a directory and all of its children.
"""
main_parent = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
if serializer.is_valid():
main_parent.classification = serializer.data['classification']
to_update = {main_parent}
to_visit = {main_parent}
while to_visit:
parent = to_visit.pop()
for child in models.Directory.objects.filter(parent=parent):
child.classification = serializer.data['classification']
to_visit.add(child)
to_update.add(child)
models.Directory.objects.bulk_update(to_update, fields=['classification'])
data = models.Directory.objects.filter(directory__in=to_update)
response = self.get_serializer(data, many=True)
return Response(response.data)
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
to_update = self._descendants(main_parent)
for directory in to_update:
directory.classification = serializer.data['classification']
models.Directory.objects.bulk_update(to_update, fields=['classification'])
return Response(self.get_serializer(to_update, many=True).data)
@swagger_auto_schema(responses={200: DirectorySerializer()})
def partial_update(self, request: Request, *args, **kwargs) -> Response:
"""
This will set the classification of a directory and none of its children.
"""
return super().update(request, *args, **kwargs)
directory = self.get_object()
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):
+18
View File
@@ -0,0 +1,18 @@
"""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
@@ -0,0 +1,48 @@
"""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
@@ -0,0 +1,31 @@
"""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
@@ -0,0 +1,203 @@
"""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
@@ -0,0 +1,42 @@
"""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
@@ -0,0 +1,87 @@
"""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
@@ -0,0 +1,82 @@
"""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
@@ -0,0 +1,135 @@
"""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
@@ -0,0 +1,56 @@
"""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
@@ -0,0 +1,17 @@
"""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
@@ -0,0 +1,14 @@
"""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
@@ -0,0 +1,22 @@
"""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.
+5 -6
View File
@@ -1,5 +1,4 @@
from dataclasses import dataclass
from typing import Optional
from comic.models import ComicBook, Directory
@@ -10,12 +9,12 @@ class Breadcrumb:
selector: str = ''
def generate_breadcrumbs_from_path(directory: Optional[Directory] = None, book: Optional[ComicBook] = None):
def generate_breadcrumbs_from_path(
directory: Directory | None = None,
book: ComicBook | None = None,
) -> list[Breadcrumb]:
output = [Breadcrumb()]
if directory:
folders = directory.get_path_objects()
else:
folders = []
folders = directory.get_path_objects() if directory else []
for item in folders[::-1]:
output.append(
Breadcrumb(
View File
Whitespace-only changes.
+27 -19
View File
@@ -1,64 +1,72 @@
version: "2.4"
services:
cbwebreader:
build: ../
image: ajurna/cbwebreader
env_file: .env
links:
- database
depends_on:
database:
condition: service_healthy
expose:
- 8000
volumes:
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
# COMIC_BOOK_VOLUME is the path *inside* the container; the host path is
# a separate variable so a Windows host path is not passed to Django.
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- static_files:/static
- media_files:/media
- .env:/src/.env
command: /bin/bash entrypoint.sh
command: sh entrypoint.sh
restart: unless-stopped
cbwebreader-cron:
build: ../
image: ajurna/cbwebreader
env_file: .env
links:
- database
depends_on:
database:
condition: service_healthy
volumes:
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- media_files:/media
- .env:/src/.env
command: /bin/bash entrypoint-cron.sh
# The image HEALTHCHECK probes the web port, which this service never serves.
healthcheck:
disable: true
command: sh entrypoint-cron.sh
restart: unless-stopped
database:
image: postgres:11.4-alpine
image: postgres:16-alpine
expose:
- 5432
volumes:
- ./data:/var/lib/postgresql/data
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"]
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 10s
retries: 3
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=${DB_DATABASE}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_DATABASE}
restart: unless-stopped
nginx:
image: nginx
image: nginx:1-alpine
volumes:
- static_files:/static
- media_files:/media
- ./nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- 1337:80
- "${WEB_PORT:-8337}:80"
depends_on:
- cbwebreader
cbwebreader:
condition: service_healthy
restart: unless-stopped
volumes:
static_files:
media_files:
postgres_data:
+7
View File
@@ -6,6 +6,13 @@ server {
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 / {
proxy_pass http://cbwebreader_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-1
View File
@@ -1 +0,0 @@
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQp477+977+9Gk3vv70sN++/vd6/77+9zoHvv73kj7JhaOiZngnvv73dgT3vv73vv73vv73Nhu+/ve+/ve+/veaSvx9Z77+9Ze+/ve+/vV53Xhjvv73vv73LsiTvv71b77+9Mmfvv73vv70477+9fzLvv73vv73vv71P77+977+9U3Rw77+977+977+977+9Oe+/ve+/ve+/ve+/vX8n77+977+977+977+977+9Pe+/vXDvv70n77+9FycX4ZymGe+/ve+/vRPvv73vv73vv703BQHvv70R77+977+977+9CU3vv71mN08fP0/vv71477+9Ce+/ve+/ve+/ve+/vQ/vv70GDBpr77+977+9Jlzvv73vv73vv73vv73vv70/Xu+/vUnvv73Kj3/vv73vv73vv73vv73vv71x77+9fu+/vXB2RDVEQ++/vVXvv73vv71xDijvv71sYu+/vWNe77+977+9K++/vQV6Nu+/vRvvv73vv73Riu+/vdG7CWAMAtCK77+90Y3vv71eGe+/ve+/vXZmPO+/vUnvv70yAwDvv73vv73vv73vv73vv70WUyzvv73vv70TCWFk77+9EWkiCe+/vRDvv73vv70SEu+/vVHvv73vv73vv73vv73ElVEHIu+/ve+/vTkySe+/vdCH77+977+9FTI477+977+9JFnvv73vv73vv70NXu+/vUjvv73vv73vv73vv70LPht/Ie+/ve+/ve+/vWcR77+9LGJo77+9RO+/ve+/ve+/vVp+Xu+/vRlm77+9V++/vT5fCj4PAu+/ve+/vX5uGe+/vVV077+977+977+9IO+/vRDvv73vv73vv73vv70JG0dE77+977+9Me+/ve+/vQLvv71V77+977+9K++/vQzvv73oiZcF77+977+977+9YD3vv71e77+9DT9A77+9LAfvv70NR0bvv71M77+977+977+9P++/vQ54Ae+/ve+/vRFU77+9MsmQ77+9wrYCNu+/ve+/vQxaLTUgeO+/ve+/ve+/vRM2XO+/vQ3vv73vv73vv73vv73vv73vv73Zju+/ve+/ve+/ve+/ve+/ve+/vXZlMu+/vRsl77+977+977+977+977+9Au+/ve+/vQbvv70077+977+977+977+977+977+977+9OiwNLO+/vRnvv70IWR7vv70877+977+977+9PO+/ve+/vXbvv73vv71YOHEFCO+/ve+/vWdZHnZm77+977+977+9NO+/ve+/ve+/ve+/vTo7We+/vSUn77+9fu+/vWcL77+977+977+9a0Hvv70k77+977+977+977+977+977+9ZO+/vRvvv70oETpS77+977+9Vu+/vRYyYEMHTXJ677+9Ze+/vUVXXRpiIFpNZWfvv71M4YK8Ou+/vRMDK9Kr77+9WmQ477+977+977+977+9YR3vv73vv70+77+9Oe+/ve+/ve+/ve+/vQ5E77+9Je+/vWc+77+9DnFwSFbvv73vv73vv70h77+9Ye+/vTNSSe+/vQcsLe+/vUNfNQpMJu+/ve+/vQlj77+9FO+/vUFd77+9P++/vSvvv70D77+9S0zvv71h241MbAPvv71N77+9KGsQ77+93Jfvv70zSjXvv70B77+9SDFh77+977+977+977+977+9Ne+/ve+/vUJjYUDvv73vv70UZgTvv70ZOQgMKQbvv71SETR02JDvv73vv73vv70O77+9Mu+/vVLvv70FX++/vQo477+9ZzXvv703Yu+/vQnvv73vv70+AXhmA0zvv73vv71C77+9P1c877+977+9Fe+/ve+/vcylHO+/vVh277+977+977+977+9Ne+/ve+/vRXvv71lK++/ve+/vWElW++/ve+/vUZFVEITUe+/vVlhb2tcZu+/ve+/vTTvv70PYO+/vX1577+9VEzvv71n77+9VUco77+9RQsA77+9I35t77+977+9Yu+/vSwg77+9DO+/ve+/vQRHJe+/ve+/vVrvv70h77+9yoLvv73vv73vv70P77+9Fe+/vQYHGO+/vULvv73vv71o77+977+9S0/vv71DeHs777+9Du+/vVsl77+9Si3vv70QWu+/ve+/vWTvv73vv70IJO+/vV1Z77+9Kg5fL++/vUg577+977+9Fu+/vWvvv73vv70o77+9JO+/ve+/vS4ISkkS77+9U++/ve+/ve+/vSpV77+977+9MmgJXe+/vSPvv71C77+9aTDvv73vv70q77+977+977+977+9Ygfvv73fqu+/vQfvv73vv716Oe+/vTLvv73vv73DqALvv71aau+/ve+/ve+/vXbvv71C77+977+9Ku+/vdSF77+9X++/vT1h77+9aO+/vQzvv71B77+977+9Iu+/vRjvv71mE++/vSoi36vvv73vv71obu+/ve+/vcWZViTvv73vv70hSe+/vTQO77+977+9VO+/ve+/vU3vv709VWfvv73vv71FN++/ve+/vW9b77+9NmY677+9au+/vXZQ77+977+977+9GiAMNjpmUURBJSkeR++/vRDvv70c5LSLKz7vv73tha3vv73vv73ZqiIn77+9de+/vUbvv70eb++/vV9kMe+/vVJ3L++/vXIH77+9YU7vv73vv709O++/vV1t77+9T++/ve+/vR/vv71977+977+977+9SH7vv70L77+9U++/ve+/vRrvv73vv71hd9yOf0cKVQDvv71k77+977+9Vmfvhq/vv73vv70477+977+977+977+9Ke+/vWEU77+9RT/Yiu+/ve+/ve+/vT3vv71ZCVrvv73vv73vv71UAHlIO++/ve+/ve+/ve+/vSjvv73vv70b77+9Klzvv70KKu+/ve+/vUfvv73vv70177+977+9Yu+/ve+/vUhZBdqB77+977+9e0fvv73vv71y77+977+9cUZ677+9VlUTY++/vUMv77+9LA8FBn0zTBLvv70VEe+/ve+/vSU9b++/vS56NzYs77+9Vmvvv70mLXbvv71qE++/vQ/vv73vv70jdtO8VRdRUu+/vUpKKChacG/vv73vv71B77+9XlXvv71y3ase77+9R++/vWpZ77+977+9Kj0dDO+/vU3vv73vv73jtatt77+9au+/vREt77+977+9dAM4xLUx77+977+9yITvv73vv71+77+977+9H1xD77+9Zu+/vWrvv71S77+977+977+977+977+977+977+9O++/vUZ377+9Be+/vVY+ZQxL77+9Gu+/vXlmKyzvv70M77+9BCps77+9UUww77+977+9GRVV77+9Owfvv70PBu+/vUgnd++/ve+/vUvvv71xN++/ve+/vW0277+977+9Vu+/ve+/vVpM77+9E1V6Fe+/vTzvv71G77+977+977+9W++/ve+/ve+/ve+/ve+/vWPvv73vv71XUy/vv73vv71VTO+/vXdAdT/vv71V77+977+9Wlfvv707U++/ve+/ve+/vQrvv73vv70o77+977+9S++/vWEC77+977+977+977+9L9GqKmzvv71+ADfvv73vv71mMmHvv73vv73dj3Xvv70qSO+/ve+/ve+/vWE2Ju+/ve+/ve+/vTfvv73vv73vv71z77+977+9dU1577+9L++/vc61Ze+/vXHvv71dJu+/ve+/vRHvv71WQ++/ve+/ve+/vciccQvvv73vv73vv71YEu+/vWrvv71KK++/vR7vv73vv73vv71777+977+9ER0077+977+9EO+/ve+/ve+/vX3vv70gaO+/vWJn77+977+9BU/vv70Qa++/ve+/ve+/ve+/vQrvv73vv73vv71u77+977+9Qe+/vVckC++/vW7vv73vv701TO+/vXLvv71+Z++/vSY977+9y6rvv71d77+977+9du+/vRUOagpm77+977+977+9QtC977+977+977+9KO+/ve+/ve+/vdi577+9E++/vUNU77+977+9SEjvv719We+/vVdCVxHvv71eFSNdHO+/vei+pyrVoRbvv73vv701JEElRe+/vWPvv71gEe+/vU/vv71P77+9HO+/vT/vv73vv73vv73vv73vv73vv71MTzB977+9EBLvv70RDT9N77+977+9Ue+/vU/vv70nJxXvv70dCu+/vRvvv70i77+9Rlxd77+977+9aXhEUu+/ve+/vQgt77+977+977+977+977+9Hu+/vVzvv71udSPvv70477+977+9XXFQxrN70oDvv73vv73vv71O77+9RO+/ve+/vVHvv73vv70FRg7vv70CXUttHUNhUe+/vWN977+9IXs6DO+/ve+/ve+/ve+/vV4I77+977+977+9WO+/ve+/vV5y77+977+9Pe+/ve+/vX5MLjlA77+9KO+/vR3Yuu+/ve+/vdWdRu+/ve+/vXciRlEw5YWNK++/ve+/vUYBDu+/ve+/vXrvv73vv73vv70DW++/ve+/vWFg77+977+9aO+/vVJbVn4hBe+/ve+/vXTvv71K77+977+977+977+9Te+/vRlD77+9Pe+/vTHvv71277+9Ee+/ve+/vW8RTO+/vQ3vv73vv73vv73vv73vv73vv73vv70sBe+/vV83Te+/vTN+Me+/vWNM77+977+9UDZ8be+/vde5FXftgafvv717ZXTvv70sG++/vdufIu+/vVbvv73vv73vv73Ug++/ve+/ve+/vWZh77+9fe+/vUho77+9fO+/ve+/vUdlUhV13Ibvv70g77+977+977+9cl/quqp6BC3vv71uC++/ve+/ve+/ve+/vRM/fiHvv73vv73vv70nee+/vQ8uB1Mw77+9Tu+/vWJWMATvv70n77+977+9f14H77+9Pu+/vTrvv70nBO+/vTwbSGFa77+9UkDLine truncated
+36 -29
View File
@@ -1,65 +1,72 @@
version: "2.4"
services:
cbwebreader:
build: .
image: ajurna/cbwebreader
env_file: .env
links:
- database
depends_on:
database:
condition: service_healthy
expose:
- 8000
volumes:
- ${COMIC_BOOK_VOLUME}:/comics
# - c:/comics:/comics
# COMIC_BOOK_VOLUME is the path *inside* the container; the host path is
# a separate variable so a Windows host path is not passed to Django.
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- static_files:/static
- media_files:/media
- .env:/src/.env
command: /bin/bash /src/entrypoint.sh
command: sh entrypoint.sh
restart: unless-stopped
# cbwebreader-cron:
# build: .
# env_file: .env
# links:
# - database
# depends_on:
# database:
# condition: service_healthy
# volumes:
# - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
# - media_files:/media
# - .env:/src/.env
# command: /bin/bash entrypoint-cron.sh
cbwebreader-cron:
build: .
image: ajurna/cbwebreader
env_file: .env
depends_on:
database:
condition: service_healthy
volumes:
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- media_files:/media
- .env:/src/.env
# The image HEALTHCHECK probes the web port, which this service never serves.
healthcheck:
disable: true
command: sh entrypoint-cron.sh
restart: unless-stopped
database:
image: postgres:16-alpine
expose:
- 5432
volumes:
- /var/lib/postgresql/data
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"]
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 10s
retries: 3
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=${DB_DATABASE}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_DATABASE}
restart: unless-stopped
nginx:
image: nginx
image: nginx:1-alpine
volumes:
- static_files:/static
- media_files:/media
- ./cbreader/settings/nginx.conf:/etc/nginx/conf.d/default.conf
- ./compose/nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- 8337:80
- "${WEB_PORT:-8337}:80"
depends_on:
- cbwebreader
cbwebreader:
condition: service_healthy
restart: unless-stopped
volumes:
static_files:
media_files:
postgres_data:
+9 -3
View File
@@ -1,6 +1,12 @@
#!/usr/bin/env sh
uv run manage.py migrate --settings=cbreader.settings.base
set -e
uv run manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
python manage.py migrate --settings=cbreader.settings.base
uv run gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
# Index the library on the first run so the UI is not empty until cron gets to it.
# --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
exec gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
+61
View File
@@ -0,0 +1,61 @@
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: {},
},
]
+4202 -8868
View File
File diff suppressed because it is too large. Load diff
+26 -39
View File
@@ -5,55 +5,42 @@
"scripts": {
"serve": "webpack-dev-server --config webpack.dev.js",
"build": "webpack --config webpack.prod.js",
"lint": "vue-cli-service lint"
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/vue-fontawesome": "^3.0.1",
"axios": "^1.8.4",
"bootstrap": "^5.2.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.3.3",
"axios": "^1.20.0",
"bootstrap": "^5.3.8",
"hammerjs": "^2.0.8",
"jwt-decode": "^4.0.0",
"reveal.js": "^5.2.1",
"reveal.js": "^6.0.2",
"timeago.js": "^4.0.2",
"vue": "^3.5.13",
"vue-router": "^4.0.3",
"vue-toast-notification": "^3.0",
"vue": "^3.5.44",
"vue-router": "^5.4.0",
"vue-toast-notification": "^3.1.3",
"vuejs-paginate-next": "^1.0.2",
"vuex": "^4.0.0",
"webpack": "^5.98.0"
"vuex": "^4.1.0"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.0",
"@vue/cli-plugin-vuex": "^5.0.0",
"@vue/cli-service": "^5.0.8",
"eslint": "^9.24.0",
"eslint-plugin-vue": "^10.0.0",
"jshint": "^2.13.5",
"mini-css-extract-plugin": "^2.9.2",
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/test-utils": "^2.5.1",
"css-loader": "^7.1.5",
"eslint": "^10.12.0",
"eslint-plugin-vue": "^10.11.1",
"jsdom": "^30.1.2",
"mini-css-extract-plugin": "^2.10.2",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "^5.3.14",
"terser-webpack-plugin": "^5.6.1",
"vitest": "^5.0.4",
"vue-loader": "^17.4.2",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-bundle-tracker": "^3.1.1",
"webpack-cli": "^6.0.1"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
"webpack": "^5.111.1",
"webpack-bundle-tracker": "^3.2.4",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0"
},
"browserslist": [
"defaults"
+30 -56
View File
@@ -1,47 +1,15 @@
import axios from "axios";
import router from "@/router";
import store from "@/store";
import { jwtDecode } from "jwt-decode";
/**
* Gets a valid access token or refreshes if needed
* Uses a consistent 5-minute threshold for token expiration
*/
async function get_access_token() {
// If we don't have tokens in the store, return null
if (!store.state.jwt || !store.state.jwt.access) {
return null;
}
// Authentication is cookie based: the browser sends the HttpOnly access token
// automatically, so there is no Authorization header to set here.
const axios_jwt = axios.create({ withCredentials: true });
try {
const access = jwtDecode(store.state.jwt.access);
const now = Date.now() / 1000;
const refreshThreshold = 300; // 5 minutes in seconds
// If token is about to expire, refresh it
if (access.exp - now < refreshThreshold) {
try {
// Wait for the token to refresh
await store.dispatch('refreshToken');
return store.state.jwt.access;
} catch (error) {
console.error('Failed to refresh token:', error);
return null;
}
}
return store.state.jwt.access;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
const axios_jwt = axios.create();
// Add CSRF token to all requests if using cookies for authentication
// Add the CSRF header if a csrftoken cookie exists. Cookie authentication is
// protected against cross-site form posts by SameSite=Lax, so this is only used
// when the backend does set the cookie.
axios_jwt.interceptors.request.use(function(config) {
// Get CSRF token from cookie if it exists
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
@@ -54,26 +22,32 @@ axios_jwt.interceptors.request.use(function(config) {
return config;
});
// Add JWT token to all requests
axios_jwt.interceptors.request.use(async function (config) {
const access_token = await get_access_token();
// A 401 means the access cookie is no longer valid. Try to refresh it once; if
// that fails the session is gone, so log out and send the user to the login page.
axios_jwt.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
if (access_token) {
config.headers.Authorization = "Bearer " + access_token;
} else if (!router.currentRoute.value.fullPath.includes('login')) {
// Only redirect if we're not already on the login page
router.push({
name: 'login',
query: {
next: router.currentRoute.value.fullPath,
error: 'Please log in to continue'
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 config;
}, function (error) {
return Promise.reject(error);
});
return Promise.reject(error);
}
);
export default axios_jwt
+3
View File
@@ -136,6 +136,9 @@ export default {
mounted() {
this.updateComicList()
},
beforeUnmount() {
clearTimeout(this.timeout)
},
}
</script>
+17 -1
View File
@@ -11,6 +11,9 @@
<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>
</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 class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto mt-1" >
@@ -49,7 +52,8 @@ export default {
filter_read: false,
filter_unread: false
},
loading: true
loading: true,
scanning: false
}},
props: {
selector: String
@@ -68,6 +72,18 @@ export default {
})
.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) {
let selectors = []
this.comics.every((item) => {
+332 -67
View File
@@ -1,19 +1,51 @@
<template>
<div class="reveal" id="comic_box" ref="comic_box" >
<div id="slides_div" class="slides" ref="slides">
<section class="" v-for="page in pages" :key="page" :data-menu-title="page" hidden>
<img :data-src="'/api/read/' + selector + '/image/' + page + '/'" class="w-100" :alt="page">
<div class="reveal" :class="{ continuous: mode === 'scroll' }" id="comic_box" ref="comic_box">
<div id="slides_div" class="slides" ref="slides">
<section
v-for="page in visible_pages"
: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>
</div>
<div class="sentinel" ref="sentinel" v-if="mode === 'scroll' && rendered < pages"></div>
</div>
<div class="row navButtons pb-2">
<comic-paginate
v-model="paginate_page"
:page_count="pages"
@setPage="this.setPage"
@prevComic="prevComic"
@nextComic="nextComic"
/>
<div class="col-auto">
<div class="btn-group btn-group-sm" role="group" aria-label="Reading mode">
<button
type="button"
class="btn btn-outline-secondary"
:class="{ active: mode === 'paged' }"
@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>
</template>
@@ -21,9 +53,17 @@
<script>
import Reveal from "reveal.js";
import api from "@/api";
import * as Hammer from 'hammerjs'
import Hammer from 'hammerjs'
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 {
name: "TheComicReader",
components: {ComicPaginate},
@@ -31,23 +71,67 @@ export default {
return {
current_page: 0,
paginate_page: 1,
inview: new Set(),
deck: null,
title: '',
prev_comic: {},
next_comic: {},
pages: 1,
rendered: 0,
visible: new Map(),
page_observer: null,
growth_observer: null,
save_timeout: null,
key_locked: false,
key_timeout: null,
}
},
props: {
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: {
prevPage(){
image_url (page) {
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()){
this.prevComic()
} else {
this.current_page -= 1
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(){
@@ -62,59 +146,140 @@ export default {
params: {selector: this.next_comic.selector}
})
},
nextPage(){
nextPage(behavior = 'smooth'){
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()){
this.nextComic()
} else {
this.current_page += 1
this.deck.slide(this.current_page)
this.scroll_to_page(this.current_page + 1, behavior)
}
},
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
}
this.current_page = pageNum-1
this.deck.slide(this.current_page)
},
keyPressDebounce(e){
clearTimeout(this.key_timeout)
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
scroll_to_page (page, behavior = 'smooth') {
const section = this.$refs.slides.querySelector('section[data-page="' + page + '"]')
if (section) {
section.scrollIntoView({behavior: behavior, block: 'start'})
}
},
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'
});
keyPress (e) {
// Alt+Left is the browser's back button and Ctrl/Cmd+Arrow moves a caret in a
// form control, so only a bare arrow key pages.
if (e.ctrlKey || e.metaKey || e.altKey) {
return
}
}
},
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
// 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.
if (e.key === 'ArrowRight') {
this.nextPage('auto')
} else if (e.key === 'ArrowLeft') {
this.prevPage('auto')
}
},
// 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.initialize({
controls: false,
@@ -130,29 +295,99 @@ export default {
plugins: [ ]
}).then(() => {
this.deck.slide(this.current_page)
api.put(set_read_url, {page: this.current_page})
this.deck.on( 'slidechanged', (event) => {
this.slideChanged = (event) => {
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
api.put(set_read_url, {page: event.indexh})
});
api.put(this.set_read_url, {page: event.indexh})
}
this.deck.on('slidechanged', this.slideChanged);
})
},
async setMode (mode) {
if (mode === this.mode) {
return
}
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()
})
this.$store.commit('setReaderMode', mode)
this.teardown()
await this.$nextTick()
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)})
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() {
window.removeEventListener('keyup', this.keyPressDebounce)
window.removeEventListener('keydown', this.keyPress)
clearTimeout(this.key_timeout)
this.teardown()
try {
this.hammertime.off('swipeleft')
this.hammertime.off('swiperight')
@@ -179,4 +414,34 @@ export default {
section {
padding-bottom: 60px;
}
/* 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>
+2 -2
View File
@@ -51,12 +51,12 @@ export default {
},
computed: {
isAuthenticated() {
return !!this.$store.state.jwt;
return this.$store.getters.isAuthenticated;
}
},
methods: {
logout () {
store.commit('logOut')
store.dispatch('logout')
router.push({name: 'login'})
}
}
@@ -175,6 +175,9 @@ export default {
this.feed_id = response.data.feed_id
})
},
beforeUnmount() {
clearTimeout(this.timeout)
},
}
</script>
+9 -4
View File
@@ -15,15 +15,20 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
/* import specific icons */
import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck} from '@fortawesome/free-solid-svg-icons'
library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck)
import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh} from '@fortawesome/free-solid-svg-icons'
library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh)
import router from './router'
import store from './store'
Vue.createApp(App)
const app = Vue.createApp(App)
.use(ToastPlugin)
.use(store)
.use(router)
.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')
})
+8 -12
View File
@@ -10,26 +10,22 @@ const LoginView = () => import('@/views/LoginView')
const HistoryView = () => import('@/views/HistoryView')
// Navigation guard to check if user is authenticated
function requireAuth(to, from, next) {
if (!store.state.jwt) {
next({
function requireAuth(to) {
if (!store.getters.isAuthenticated) {
return {
name: 'login',
query: { next: to.fullPath, error: 'Please log in to access this page' }
});
} else {
next();
};
}
}
// Navigation guard to check if user is admin
function requireAdmin(to, from, next) {
if (!store.state.jwt || !store.getters.is_superuser) {
next({
function requireAdmin(to) {
if (!store.getters.isAuthenticated || !store.getters.is_superuser) {
return {
name: 'login',
query: { next: to.fullPath, error: 'Admin access required' }
});
} else {
next();
};
}
}
+92 -101
View File
@@ -1,16 +1,11 @@
import { createStore } from 'vuex'
import axios from 'axios'
import { jwtDecode } from "jwt-decode";
import {useToast} from "vue-toast-notification";
import router from "@/router";
import api from "@/api";
// We'll no longer use localStorage for tokens
// Instead, tokens will be stored in httpOnly cookies by the backend
// and automatically included in requests
function get_jwt_from_storage(){
return null; // Initial state will be null until login
}
// Tokens live in HttpOnly cookies set by the backend, so JavaScript cannot read
// them. Only the user profile is kept in localStorage, to avoid a blank page
// before the first request has been made.
function get_user_from_storage(){
try {
return JSON.parse(localStorage.getItem('u'))
@@ -19,11 +14,27 @@ 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({
state: {
jwt: get_jwt_from_storage(),
filters: {},
user: get_user_from_storage(),
reader_mode: get_reader_mode_from_storage(),
classifications: [
{label: 'G', value: '0'},
{label: 'PG', value: '1'},
@@ -39,29 +50,27 @@ export default createStore({
} else {
return state.user.is_superuser
}
},
isAuthenticated (state) {
return state.user !== null
}
},
mutations: {
updateToken(state, newToken){
// No longer storing tokens in localStorage
// Tokens are stored in httpOnly cookies by the backend
state.jwt = newToken;
},
logOut(state){
// Clear user data from localStorage
localStorage.removeItem('u')
// Clear state
// Make a request to the backend to invalidate the token
axios.post('/api/token/blacklist/', { refresh: state.jwt?.refresh })
.catch(error => console.error('Error blacklisting token:', error));
state.jwt = null;
state.user = null
},
updateUser(state, userData){
localStorage.setItem('u', JSON.stringify(userData));
state.user = userData
},
setReaderMode(state, mode){
if (mode !== 'paged' && mode !== 'scroll') {
return
}
localStorage.setItem('reader_mode', mode);
state.reader_mode = mode
},
},
actions: {
obtainToken(context, {username, password}){
@@ -69,95 +78,77 @@ export default createStore({
username: username,
password: password
}
axios.post('/api/token/', payload)
.then((response)=>{
context.commit('updateToken', response.data);
api.get('/api/account').then(response => {
context.commit('updateUser', response.data)
})
if ('next' in router.currentRoute.value.query) {
router.push(router.currentRoute.value.query.next)
} else {
router.push('browse')
}
})
.catch((error)=>{
const $toast = useToast();
if (error.response.data.detail) {
$toast.error(error.response.data.detail, {position:'top'});
}
if (error.response.data.username) {
$toast.error("Username: " + error.response.data.username, {position:'top'});
}
if (error.response.data.password) {
$toast.error("Password: " + error.response.data.password, {position:'top'});
}
})
},
refreshToken(){
// Don't attempt to refresh if we don't have a token
if (!this.state.jwt || !this.state.jwt.refresh) {
return Promise.reject(new Error('No refresh token available'));
}
const payload = {
refresh: this.state.jwt.refresh
}
return axios.post('/api/token/refresh/', payload)
// The response body is empty: the backend stores the tokens as HttpOnly cookies.
api.post('/api/token/', payload)
.then(() => api.get('/api/account'))
.then((response) => {
this.commit('updateToken', response.data);
return response.data;
context.commit('updateUser', response.data)
if ('next' in router.currentRoute.value.query) {
router.push(router.currentRoute.value.query.next)
} else {
router.push('browse')
}
})
.catch((error) => {
const $toast = useToast();
const data = error.response?.data ?? {};
if (data.detail) {
$toast.error(data.detail, {position:'top'});
}
if (data.username) {
$toast.error("Username: " + data.username, {position:'top'});
}
if (data.password) {
$toast.error("Password: " + data.password, {position:'top'});
}
})
},
refreshToken(){
if (!this.state.user) {
return Promise.reject(new Error('Not logged in'));
}
// The refresh token is read from the cookie by the backend.
return api.post('/api/token/refresh/', {})
.then((response) => response.data)
.catch((error) => {
console.error('Token refresh failed:', error);
// If refresh fails, log the user out and redirect to login
this.commit('logOut');
router.push({
name: 'login',
query: {
next: router.currentRoute.value.fullPath,
error: 'Your session has expired. Please log in again.'
}
});
router.push(session_expired_query(router.currentRoute.value.fullPath));
return Promise.reject(error);
});
},
inspectToken(){
const token = this.state.jwt;
if (!token) return;
try {
// For access token
const decoded = jwtDecode(token.access);
const exp = decoded.exp;
const now = Date.now() / 1000;
// Refresh when token is within 5 minutes of expiring
const refreshThreshold = 300; // 5 minutes in seconds
if (exp - now < refreshThreshold) {
// Token is about to expire, refresh it
this.dispatch('refreshToken');
} else if (exp < now) {
// Token is already expired, force logout
this.commit('logOut');
router.push({
name: 'login',
query: {
next: router.currentRoute.value.fullPath,
error: 'Your session has expired. Please log in again.'
}
});
}
} catch (error) {
console.error('Error inspecting token:', error);
// If we can't decode the token, log the user out
this.commit('logOut');
router.push({name: 'login'});
// Check that the cookie still identifies the user stored in localStorage.
restoreSession(){
if (!this.state.user) {
return Promise.resolve(null);
}
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: {
+4 -9
View File
@@ -15,25 +15,20 @@ export default {
},
data () {
return {
comic_data: {},
comic_loaded: false,
}
},
methods: {
updateType() {
let comic_data_url = '/api/read/' + this.selector + '/type/'
api.get(comic_data_url)
.then(response => {
checkComicExists() {
api.get('/api/read/' + this.selector + '/type/')
.then(() => {
this.comic_loaded = true
})
.catch((error) => {console.log(error)})
}
},
mounted () {
this.updateType()
},
beforeUpdate() {
this.updateType()
this.checkComicExists()
}
}
</script>
+1 -1
View File
@@ -67,7 +67,7 @@ export default {
}
},
watch: {
$route(to, from) {
$route() {
this.updateUsers()
this.crumbs = [...default_crumbs]
if (this.userid){
+114
View File
@@ -0,0 +1,114 @@
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
@@ -0,0 +1,133 @@
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'] })
})
})
+428
View File
@@ -0,0 +1,428 @@
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 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
@@ -0,0 +1,107 @@
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
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,127 @@
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
@@ -1,26 +0,0 @@
{
"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
@@ -0,0 +1,18 @@
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
@@ -1,10 +0,0 @@
const { defineConfig } = require('@vue/cli-service')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
.BundleAnalyzerPlugin;
module.exports = defineConfig({
transpileDependencies: true,
configureWebpack: {
plugins: [new BundleAnalyzerPlugin()]
},
})
+8 -9
View File
@@ -3,6 +3,11 @@ const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker');
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 = () => {
return {
@@ -11,6 +16,7 @@ module.exports = () => {
entry: path.resolve(__dirname, './src/main.js'),
output: {
path: path.resolve(__dirname, './dist/bundles/'),
publicPath: DEV_PUBLIC_PATH,
},
module: {
rules: [
@@ -18,14 +24,6 @@ module.exports = () => {
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{
test: /\.css$/,
use: [
@@ -47,7 +45,7 @@ module.exports = () => {
new BundleTracker({
filename: 'webpack-stats.json',
path: path.resolve(__dirname, './'),
publicPath: 'http://localhost:8080/'
publicPath: DEV_PUBLIC_PATH
}),
new webpack.DefinePlugin({
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
@@ -55,6 +53,7 @@ module.exports = () => {
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }),
],
devServer: {
port: DEV_PORT,
headers: {
"Access-Control-Allow-Origin":"*"
},
+3 -9
View File
@@ -11,7 +11,9 @@ module.exports = (env = {}) => {
return {
mode: 'production',
devtool: 'hidden-source-map',
// No source maps in production: they are not served to browsers, but collectstatic
// copies them into /static/ so browser devtools would pull ~3.3 MiB of maps.
devtool: false,
entry: path.resolve(__dirname, './src/main.js'),
output: {
path: path.resolve(__dirname, './dist/bundles/'),
@@ -22,14 +24,6 @@ module.exports = (env = {}) => {
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
View File
Whitespace-only changes.
+62 -35
View File
@@ -1,46 +1,73 @@
[project]
name = "cbwebreader"
version = "1.1.3"
version = "2.0.0"
description = "CBR/Z Web Reader"
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.14"
authors = ["ajurna <ajurna@gmail.com>"]
license = "Creative Commons Attribution-ShareAlike 4.0 International License"
dependencies = [
"dj-database-url>=2.3.0",
"django>=5.1.7",
"django-boost>=2.1",
"django-bootstrap4>=25.1",
"django-cors-headers>=4.7.0",
"django-csp>=3.8",
"django-extensions>=3.2.3",
"django-filter>=25.1",
"django-imagekit>=5.0.0",
"django-permissions-policy>=4.25.0",
"django-silk>=5.3.2",
"django-sri>=0.8.0",
"django-webpack-loader>=3.1.1",
"djangorestframework>=3.16.0",
"djangorestframework-simplejwt>=5.5.0",
"drf-yasg>=1.21.10",
"flake8>=7.2.0",
"flake8-annotations>=3.1.1",
"gunicorn>=23.0.0",
"dj-database-url>=3.1.2",
"django>=6.1.2",
"django-boost>=2.2",
"django-cors-headers>=4.9.0",
"django-csp>=4.0",
"django-extensions>=4.1",
"django-filter>=26.2",
"django-imagekit>=6.1.1",
"django-permissions-policy>=4.34.0",
"django-silk>=5.6.0",
"django-webpack-loader>=3.2.4",
"djangorestframework>=3.18.3",
"djangorestframework-simplejwt>=5.5.1",
"drf-yasg>=1.21.18",
"gunicorn>=26.2.0",
"loguru>=0.7.3",
"mysqlclient>=2.2.7",
"pillow>=11.1.0",
"psycopg2>=2.9.10",
"pymupdf>=1.25.5",
"python-dotenv>=1.1.0",
"rarfile>=4.2",
"pillow>=12.3.0",
"psycopg2-binary>=2.9.13",
"pymupdf>=1.28.2",
"python-dotenv>=1.2.4",
"rarfile>=4.5",
]
[dependency-groups]
dev = [
"coverage>=7.8.0",
"ipython>=9.0.2",
"mypy>=1.15.0",
"pre-commit>=4.2.0",
"pylint>=3.3.6",
"pylint-django>=2.6.1",
"pyopenssl>=25.0.0",
"werkzeug>=3.1.3",
"coverage>=7.13.5",
"ipython>=9.13.0",
"mypy>=2",
"pre-commit>=4.6",
"pylint>=4.0.5",
"pylint-django>=2.7.0",
"pyopenssl>=26.2.0",
"pytest>=8.4",
"pytest-django>=4.8",
"ruff>=0.17.0",
"werkzeug>=3.1.8",
]
[tool.ruff]
target-version = "py314"
line-length = 120
extend-exclude = [
"*/migrations/*",
"cbreader/settings",
"frontend",
]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "ANN", "B", "C4", "SIM", "UP"]
# These were ignored by the previous flake8-annotations setup (ANN001 is the
# 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
@@ -1,3 +0,0 @@
[pytest]
pep8maxlinelength = 119
python_files = tests.py test_*.py *_tests.py
-26
View File
@@ -1,26 +0,0 @@
[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
@@ -1,12 +0,0 @@
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
+791 -492
View File
File diff suppressed because it is too large. Load diff