Author SHA1 Message Date
dependabot[bot] 74002dbf9f Bump virtualenv from 20.36.1 to 21.14.13
Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.36.1 to 21.14.13.
- [Release notes](https://github.com/pypa/virtualenv/releases)
- [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/virtualenv/compare/20.36.1...21.14.13)

---
updated-dependencies:
- dependency-name: virtualenv
  dependency-version: 21.14.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 23:43:36 +00:00
ajurna bb47478511 Add keyboard shortcut help and improved navigation controls
Build and push image / deploy (push) Canceled after 0s
- Introduced a modal to display keyboard shortcuts with a toggle button (`?`) in the interface.
- Added support for toggling shortcuts list visibility via `?` and `Escape` keys.
- Enabled navigation with additional shortcuts: `n/p`, `Home/End`, `m`, and `Space/Shift+Space` for improved usability.
- Modified keypress handling to accommodate new shortcuts while preventing default browser scroll actions where necessary.
- Updated styles to ensure proper positioning and accessibility of the shortcut list.
2026-10-16 09:41:45 +01: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 e5086ec653 Add authentication and session management improvements
Build and push image / deploy (push) Has been cancelled
Introduce navigation guards for authentication and admin access within routes. Replace localStorage usage with secure token storage via httpOnly cookies, and add token blacklisting upon logout. Enhance token refresh mechanism and session expiration handling to improve security and user experience.
2025-05-21 22:53:29 +01:00
ajurna dd5817419b Remove ThePdfReader.vue and migrate PDF handling to pymupdf
This commit removes the frontend component ThePdfReader.vue and replaces its functionality with a backend implementation based on pymupdf. Also includes package updates, refactors PDF archive handling, and adjusts security settings to support development on localhost.
2025-05-21 22:30:34 +01:00
ajurna b01eb60eeb security fixes
Build and push image / deploy (push) Has been cancelled
2025-04-04 08:35:10 +01:00
ajurna 306b237b01 security fixes 2025-04-04 08:21:10 +01:00
ajurna a7cb857c00 security fixes 2025-04-04 08:20:24 +01:00
ajurna 0adfba1275 fixes
Build and push image / deploy (push) Has been cancelled
2025-04-02 13:29:32 +01:00
ajurna 708df71220 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.pre-commit-config.yaml
#	Dockerfile
#	frontend/package-lock.json
#	frontend/package.json
#	poetry.lock
#	pyproject.toml
2025-04-02 11:35:56 +01:00
ajurna 871f930727 update libs 2025-04-02 11:32:47 +01: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
ajurna 3ae5b6b23b updating deps
Build and push image / deploy (push) Has been cancelled
2024-04-15 16:23:56 +01:00
ajurna 50d46ab835 updating deps
Build and push image / deploy (push) Has been cancelled
2023-10-13 10:17:44 +01:00
Peter Dwyer 40bd6f7e7f fixing build
Build and push image / deploy (push) Successful in 3m30s
2023-08-29 11:54:46 +01:00
Peter Dwyer d46517745e fixing build 2023-08-29 11:43:54 +01:00
Peter Dwyer a7206bb7f8 fixing build
Build and push image / deploy (push) Successful in 26m40s
2023-08-29 10:56:58 +01:00
Peter Dwyer df0caf709e updating deps
Build and push image / deploy (push) Failing after 19m14s
2023-08-29 08:53:37 +01:00
Peter Dwyer 7049f3fa3b updating deps
Build and push image / deploy (push) Failing after 19m14s
2023-08-28 16:22:37 +01:00
Peter Dwyer a540cb7efd updating deps
Build and push image / deploy (push) Failing after 3m15s
2023-08-28 15:54:11 +01:00
Peter Dwyer 7531e5b1fc updating deps 2023-08-28 15:53:50 +01:00
Peter Dwyer 8e5654db11 updating deps
Build and push image / deploy (push) Failing after 4m46s
2023-08-28 15:42:25 +01:00
Peter Dwyer 4e45374365 updating deps
Build and push image / deploy (push) Has been cancelled
2023-08-28 15:33:45 +01:00
Peter Dwyer c303bca9d5 updating deps 2023-08-28 15:33:26 +01:00
Peter Dwyer 4d20e6042a updating deps 2023-08-28 15:32:27 +01:00
Peter Dwyer 3a3fd3c560 updating deps 2023-08-28 15:26:17 +01:00
Peter Dwyer 53e75ae660 working on builds
Build and push image / deploy (push) Successful in 24m14s
2023-08-28 14:40:10 +01:00
Peter Dwyer c3f2cb94aa working on builds
Build and push image / deploy (push) Successful in 1m3s
2023-08-28 14:36:56 +01:00
Peter Dwyer dad9c78344 working on builds
Build and push image / deploy (push) Successful in 1m21s
2023-08-28 14:34:56 +01:00
Peter Dwyer 9e361381ba working on builds
Build and push image / deploy (push) Successful in 1m7s
2023-08-28 14:27:48 +01:00
Peter Dwyer be293bf90f working on builds
Build and push image / deploy (push) Failing after 4m33s
2023-08-28 14:09:09 +01:00
Peter Dwyer bfa768405b working on builds
Build and push image / deploy (push) Failing after 4m0s
2023-08-28 14:02:42 +01:00
Peter Dwyer 95f3900089 working on builds
Build and push image / deploy (push) Failing after 4m8s
2023-08-28 13:55:14 +01:00
Peter Dwyer cd5ac1ce56 working on builds
Build and push image / deploy (push) Failing after 3m19s
2023-08-28 13:46:34 +01:00
Peter Dwyer d71a4e6f8d working on builds
Build and push image / deploy (push) Failing after 36s
2023-08-28 13:44:49 +01:00
Peter Dwyer b39342f751 working on builds
Build and push image / deploy (push) Failing after 3m32s
2023-08-28 13:40:00 +01:00
Peter Dwyer c115d0bb82 working on builds
Build and push image / deploy (push) Failing after 7s
2023-08-28 13:39:25 +01:00
Peter Dwyer 55093e9a28 working on builds
Build and push image / deploy (push) Failing after 8s
2023-08-28 13:36:32 +01:00
Peter Dwyer 8a69766d5d working on builds
Build and push image / deploy (push) Failing after 11s
2023-08-28 13:34:52 +01:00
Peter Dwyer c3671944d2 working on builds
Build and push image / deploy (push) Failing after 12s
2023-08-28 13:33:29 +01:00
Peter Dwyer 5788f7d7e0 working on builds
Build and push image / deploy (push) Failing after 9s
2023-08-28 13:28:13 +01:00
Peter Dwyer 7c7c3623d4 working on builds
Build and push image / deploy (push) Failing after 7s
2023-08-28 13:26:44 +01:00
Peter Dwyer 33bf7eca90 working on builds
Build and push image / deploy (push) Failing after 10s
2023-08-28 13:24:23 +01:00
Peter Dwyer 899d021c63 working on builds
Build and push image / deploy (push) Failing after 10s
2023-08-28 13:17:16 +01:00
Peter Dwyer 9dac8304ec working on builds
Build and push image / deploy (push) Failing after 1m57s
2023-08-28 13:10:17 +01:00
Peter Dwyer dda5bf31b2 working on builds
Build and push image / deploy (push) Failing after 2m29s
2023-08-28 13:04:49 +01:00
Peter Dwyer 3d8dac206e working on builds
Build and push image / deploy (push) Successful in 24m18s
2023-08-28 12:14:14 +01:00
Peter Dwyer 26cd40b47c working on builds
Build and push image / deploy (push) Failing after 30s
2023-08-28 12:01:15 +01:00
Peter Dwyer 4f79d58442 working on builds
Build and push image / deploy (push) Failing after 8s
2023-08-28 11:40:36 +01:00
Peter Dwyer 6155919514 working on builds
Build and push image / deploy (push) Failing after 9s
2023-08-28 11:38:03 +01:00
Peter Dwyer fdc577d33e working on builds
Build and push image / deploy (push) Failing after 22s
2023-08-28 11:35:40 +01:00
Peter Dwyer c092b0c9b2 working on builds
Build and push image / deploy (push) Failing after 59s
2023-08-28 11:31:03 +01:00
Peter Dwyer 5b15aa0de8 working on builds 2023-08-28 11:25:22 +01:00
Peter Dwyer 2e93940a67 working on builds 2023-08-28 11:23:40 +01:00
Peter Dwyer 05f1e95f14 wokring on builds 2023-08-28 11:17:14 +01:00
Peter Dwyer db23064929 library bumps
version bump.
added inital build action
2023-08-28 11:14:53 +01:00
dependabot[bot]andajurna bccbff8b33 Bump sqlparse from 0.4.3 to 0.4.4 (#93)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.3 to 0.4.4.
- [Release notes](https://github.com/andialbrecht/sqlparse/releases)
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.3...0.4.4)

---
updated-dependencies:
- dependency-name: sqlparse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ajurna <ajurna@gmail.com>
2023-05-23 08:50:16 +01:00
dependabot[bot]andajurna 05e3db9e42 Bump django from 4.1.7 to 4.1.9 (#94)
Bumps [django](https://github.com/django/django) from 4.1.7 to 4.1.9.
- [Commits](https://github.com/django/django/compare/4.1.7...4.1.9)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ajurna <ajurna@gmail.com>
2023-05-23 08:48:02 +01:00
dependabot[bot] a47153b917 Bump requests from 2.28.2 to 2.31.0 (#95)
Bumps [requests](https://github.com/psf/requests) from 2.28.2 to 2.31.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.28.2...v2.31.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-23 08:45:45 +01:00
dependabot[bot] df01235325 Bump webpack from 5.75.0 to 5.76.0 in /frontend (#92)
Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.76.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.76.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-17 15:54:34 +01:00
Peter Dwyer 7d20447526 library bumps
version bump.
2023-02-16 09:55:32 +00:00
Peter Dwyer 0dfe551b66 library bumps
version bump.
2023-02-16 09:36:50 +00:00
Peter Dwyer 94171ec548 library bumps
version bump.
2023-02-07 14:44:12 +00:00
Peter Dwyer 1fbe359448 library bumps
fix for LoginView.vue reloading instead of logging in.
improved errors for login failing
misc cleanup.
2023-02-07 14:39:26 +00:00
94 changed files with 11150 additions and 13625 deletions

No files matched your search

+2 -4
View File
@@ -3,16 +3,14 @@
!cbreader !cbreader
!comic !comic
!comic_auth
!static !static
!manage.py !manage.py
!pyproject.toml !pyproject.toml
!setup.cfg !uv.lock
!entrypoint.sh !entrypoint.sh
!entrypoint-cron.sh !entrypoint-cron.sh
!requirements.txt
!package-lock.json !package-lock.json
!package.json !package.json
!frontend !frontend
/frontend/node_modules /frontend/node_modules
/frontend/dist /frontend/dist
+5 -3
View File
@@ -15,8 +15,10 @@ DB_DATABASE=cbwebreader
# https://github.com/jacobian/dj-database-url # https://github.com/jacobian/dj-database-url
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE} DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}
#Path to your comics. # Path to your comics *inside* the container. Keep this as a container path.
COMIC_BOOK_VOLUME=/media/plex/comics COMIC_BOOK_VOLUME=/comics
# Host path that is mounted to the path above.
COMIC_BOOK_HOST_PATH=/media/plex/comics
STATIC_ROOT='/static' STATIC_ROOT='/static'
@@ -32,4 +34,4 @@ MEDIA_ROOT='/media'
# DJANGO_RECAPTCHA_PUBLIC_KEY = '' # DJANGO_RECAPTCHA_PUBLIC_KEY = ''
# Comment the following if not using a reverse proxy. # Comment the following if not using a reverse proxy.
USE_X_FORWARDED_HOST=True USE_X_FORWARDED_HOST=True
+5 -3
View File
@@ -15,8 +15,10 @@ DB_DATABASE=cbwebreader
# https://github.com/jacobian/dj-database-url # https://github.com/jacobian/dj-database-url
DATABASE_URL='postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}' DATABASE_URL='postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}'
# Path to your comics. # Path to your comics *inside* the container. Keep this as a container path.
COMIC_BOOK_VOLUME=/path/to/comic/folder 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. # Usually fine tp leave as is.
STATIC_ROOT=/static STATIC_ROOT=/static
@@ -29,4 +31,4 @@ MEDIA_ROOT='/media'
# UNRAR_TOOL = 'unrar.exe' # UNRAR_TOOL = 'unrar.exe'
# Comment the following if not using a reverse proxy. # Comment the following if not using a reverse proxy.
USE_X_FORWARDED_HOST=True USE_X_FORWARDED_HOST=True
+32
View File
@@ -0,0 +1,32 @@
name: Build and push image
on:
push:
branches: [master]
jobs:
deploy:
runs-on: ubuntu-22.04
steps:
- name: Install Docker
run: curl -fsSL https://get.docker.com | sh
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- 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 }}
- uses: actions/checkout@v4
- name: Install uv
run: pip install uv
# Extract version from the project metadata
- name: Get version
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.PROJECT_VERSION }} .
docker push ajurna/cbwebreader:${{ env.PROJECT_VERSION }}
docker push ajurna/cbwebreader:latest
-73
View File
@@ -1,73 +0,0 @@
# Contributor Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
-25
View File
@@ -1,25 +0,0 @@
**I'm submitting a ...**
- [ ] bug report
- [ ] feature request
- [ ] support request => Please do not submit support request here, see note at the top of this template.
**What is the current behavior?**
**If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem**
**What is the expected behavior?**
**What is the motivation / use case for changing the behavior?**
**Please tell us about your environment:**
- Version: 0.0.1
- Browser: [all | Chrome XX | Firefox XX | IE XX | Safari XX | Mobile Chrome XX | Android X.X Web Browser | iOS XX Safari | iOS XX UIWebView | iOS XX WKWebView ]
- Language: [all | Python | Other]
**Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, gitter, etc)
-173
View File
@@ -1,173 +0,0 @@
## creative commons
# Attribution-ShareAlike 4.0 International
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
### Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
* __Considerations for licensors:__ Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors).
* __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).
## Creative Commons Attribution-ShareAlike 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
### Section 1 – Definitions.
a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. __Adapter's License__ means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. __BY-SA Compatible License__ means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License.
d. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
g. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
j. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
k. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. __You__ means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
### Section 2 – Scope.
a. ___License grant.___
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; and
B. produce, reproduce, and Share Adapted Material.
2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. __Term.__ The term of this Public License is specified in Section 6(a).
4. __Media and formats; technical modifications allowed.__ The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. __Downstream recipients.__
A. __Offer from the Licensor – Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. __Additional offer from the Licensor – Adapted Material.__ Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
C. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. ___Other rights.___
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
### Section 3 – License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. ___Attribution.___
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. ___ShareAlike.___
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
### Section 4 – Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
### Section 5 – Disclaimer of Warranties and Limitation of Liability.
a. __Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.__
b. __To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.__
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
### Section 6 – Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
### Section 7 – Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
### Section 8 – Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
> Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” The text of the Creative Commons public licenses is dedicated to the public domain under the [CC0 Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0/legalcode). Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
>
> Creative Commons may be contacted at creativecommons.org.
-9
View File
@@ -1,9 +0,0 @@
## Whats changed
#### **Please check if the PR fulfills these requirements**
- [ ] The commit message follows the guidelines outline in the repository
- [ ] Tests for the changes have been added (for bug fixes / features)
- [ ] Docs have been added / updated (for bug fixes / features)
- [ ] Passes CI and does not contain merge conflicts with Master.
-71
View File
@@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '43 5 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
-33
View File
@@ -1,33 +0,0 @@
name: Docker Image CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
poetry-version: ["1.1.15"]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Run image
uses: abatilo/actions-poetry@v2.0.0
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Generate Requirements
run: poetry export --without-hashes -f requirements.txt --output requirements.txt
- name: Set env
run: echo "CB_VERSION=$(poetry version -s)" >> $GITHUB_ENV
- name: Build the Docker image
run: docker build . --file Dockerfile --tag ajurna/cbwebreader:$CB_VERSION
+17
View File
@@ -96,3 +96,20 @@ node_modules
localhost-key.pem localhost-key.pem
localhost.pem localhost.pem
webpack-stats.json webpack-stats.json
# Test artifacts
.pytest_cache/
test_comics/
test_media/
htmlcov/
.coverage
# Virtual environment created by uv
.venv/
# Tool caches
.mypy_cache/
.ruff_cache/
# Local-only test fixtures (kept on disk, not in git)
data
+5 -13
View File
@@ -2,22 +2,14 @@
# See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.com/hooks.html for more hooks
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0 rev: v4.5.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/python-poetry/poetry - repo: https://github.com/astral-sh/ruff-pre-commit
rev: '1.2.1' # add version here rev: v0.17.0
hooks: hooks:
- id: poetry-check - id: ruff
- id: poetry-export args: [ --fix ]
args: ["--without-hashes", "-o", "requirements.txt"]
- repo: https://github.com/pycqa/flake8
rev: "5.0.4"
hooks:
- id: flake8
additional_dependencies: [
'flake8-annotations==2.9.1',
]
+1 -1
View File
@@ -1,6 +1,6 @@
[MAIN] [MAIN]
max-line-length=120 max-line-length=120
ignore-paths=.*/migrations ignore-paths=comic/migrations
load-plugins = pylint_django load-plugins = pylint_django
disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring
good-names=pk good-names=pk
+1
View File
@@ -0,0 +1 @@
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
+44 -24
View File
@@ -1,37 +1,57 @@
FROM python:3.10-slim-bullseye # ---------------------------------------------------------------------------
# Frontend build stage
# ---------------------------------------------------------------------------
FROM node:22-slim AS frontend
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM python:3.14-slim-trixie
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONFAULTHANDLER=1 \ ENV PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \ PYTHONHASHSEED=random \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1
PIP_DEFAULT_TIMEOUT=100 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
RUN mkdir /src ENV UV_PROJECT_ENVIRONMENT=/opt/venv
RUN mkdir /static 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 WORKDIR /src
# Lockfiles first so dependency installation is cached independently of the source.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . /src/ COPY . /src/
COPY --from=frontend /src/dist /src/frontend/dist
COPY --from=frontend /src/webpack-stats.json /src/frontend/webpack-stats.json
RUN mkdir -p /static /media
RUN apt update \ RUN cat /src/cbreader/crontab >> /etc/crontab
&& apt install -y software-properties-common \
&& apt-add-repository non-free \
&& apt update \
&& apt install -y npm cron unrar libmariadb-dev libpq-dev \
&& pip install --upgrade pip \
&& pip install -r requirements.txt \
&& cd frontend \
&& npm install \
&& npm run build \
&& apt remove -y npm software-properties-common \
&& rm -r node_modules \
&& apt -y auto-remove \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*
RUN cat /src/cbreader/crontab >> /etc/cron.daily/cbreader
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1
CMD ["sh", "entrypoint.sh"]
+2 -3
View File
@@ -1,4 +1,3 @@
poetry export --without-hashes -f requirements.txt --output requirements.txt $version=uv version --short
$version=poetry version -s
docker build . -t ajurna/cbwebreader:beta --no-cache docker build . -t ajurna/cbwebreader:beta --no-cache
# docker push ajurna/cbwebreader:beta # docker push ajurna/cbwebreader:beta
+3 -4
View File
@@ -1,4 +1,3 @@
poetry export --without-hashes -f requirements.txt --output requirements.txt $version=uv version --short
$version=poetry version -s docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
docker build . --no-cache -t ajurna/cbwebreader -t ajurna/cbwebreader:$version docker push ajurna/cbwebreader --all-tags
docker push ajurna/cbwebreader --all-tags
+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
+58 -13
View File
@@ -6,6 +6,8 @@ Django settings for cbreader project.
import os import os
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import Dict, List
from urllib.parse import quote
import dj_database_url import dj_database_url
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -27,7 +29,7 @@ ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")
# Application definition # Application definition
INSTALLED_APPS = ( INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
@@ -36,17 +38,16 @@ INSTALLED_APPS = (
"django.contrib.staticfiles", "django.contrib.staticfiles",
'drf_yasg', 'drf_yasg',
'webpack_loader', 'webpack_loader',
'bootstrap4',
"comic", "comic",
'django_extensions', 'django_extensions',
'imagekit', 'imagekit',
'django_boost', 'django_boost',
'sri',
"corsheaders", "corsheaders",
'django_filters', 'django_filters',
'rest_framework', 'rest_framework',
'rest_framework_simplejwt.token_blacklist',
# 'silk' # 'silk'
) ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
@@ -58,8 +59,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
# 'silk.middleware.SilkyMiddleware', 'csp.middleware.CSPMiddleware',
# 'csp.middleware.CSPMiddleware',
] ]
ROOT_URLCONF = "cbreader.urls" ROOT_URLCONF = "cbreader.urls"
@@ -73,8 +73,19 @@ WSGI_APPLICATION = "cbreader.wsgi.application"
DATABASE_URL = os.getenv("DATABASE_URL") DATABASE_URL = os.getenv("DATABASE_URL")
# docker-compose passes DB_* variables, not a URL. Without building one from them the
# app silently falls back to sqlite inside the container, and the web and cron
# containers end up with two separate databases.
if not DATABASE_URL and os.getenv("DB_HOST"):
DATABASE_URL = "postgres://{}:{}@{}/{}".format(
quote(os.getenv("DB_USER", "postgres"), safe=""),
quote(os.getenv("DB_PASS", ""), safe=""),
os.getenv("DB_HOST"),
os.getenv("DB_DATABASE", "postgres"),
)
if DATABASE_URL: if DATABASE_URL:
DATABASES = {"default": dj_database_url.config(conn_max_age=500)} DATABASES = {"default": dj_database_url.parse(DATABASE_URL, conn_max_age=500)}
else: else:
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3")}} DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3")}}
@@ -191,16 +202,19 @@ BOOTSTRAP4 = {
}, },
} }
CSP_DEFAULT_SRC = ("'none'",) CSP_DEFAULT_SRC = ("'none'",)
CSP_STYLE_SRC = ("'self'", "'sha256-MBVp6JYxbC/wICelYC6eULCRpgi9kGezXXSaq/TS2+I='") CSP_STYLE_SRC = (
"'self'",
"'unsafe-inline'"
)
CSP_IMG_SRC = ("'self'", "data:") CSP_IMG_SRC = ("'self'", "data:")
CSP_FONT_SRC = ("'self'",) CSP_FONT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",) CSP_SCRIPT_SRC = ("'self'", "'unsafe-eval'", "'unsafe-inline'", "localhost:8080")
CSP_CONNECT_SRC = ("'self'",) CSP_CONNECT_SRC = ("'self'", "ws://localhost:8080/ws")
CSP_INCLUDE_NONCE_IN = ['script-src'] CSP_INCLUDE_NONCE_IN = ['script-src']
CSP_SCRIPT_SRC_ATTR = ("'self'",)# "'unsafe-inline'") CSP_SCRIPT_SRC_ATTR = ("'self'",) # "'unsafe-inline'")
PERMISSIONS_POLICY = { PERMISSIONS_POLICY: Dict[str, List] = {
"accelerometer": [], "accelerometer": [],
"ambient-light-sensor": [], "ambient-light-sensor": [],
"autoplay": [], "autoplay": [],
@@ -227,15 +241,46 @@ REST_FRAMEWORK = {
], ],
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework_simplejwt.authentication.JWTAuthentication',
'comic.auth.CookieJWTAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
) )
} }
CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_ALL_ORIGINS = True
# Cookie based JWT authentication (see comic/auth.py). Tokens are stored in HttpOnly
# cookies so that browser initiated requests, such as the page images in the reader,
# can be authenticated - an <img> tag cannot send an Authorization header.
JWT_ACCESS_COOKIE_NAME = os.getenv('JWT_ACCESS_COOKIE_NAME', 'access_token')
JWT_REFRESH_COOKIE_NAME = os.getenv('JWT_REFRESH_COOKIE_NAME', 'refresh_token')
JWT_COOKIE_PATH = os.getenv('JWT_COOKIE_PATH', '/')
# Only mark the cookies Secure when the site is actually served over HTTPS, otherwise
# browsers drop them on plain http.
JWT_COOKIE_SECURE = os.getenv('JWT_COOKIE_SECURE', 'False') == 'True'
# Lax blocks cross-site POSTs, which is the CSRF protection for cookie authentication.
JWT_COOKIE_SAMESITE = os.getenv('JWT_COOKIE_SAMESITE', 'Lax')
SIMPLE_JWT = { SIMPLE_JWT = {
"ROTATE_REFRESH_TOKENS": True, "ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10), 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
'LEEWAY': timedelta(minutes=5), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'LEEWAY': timedelta(seconds=30),
'ALGORITHM': 'HS256',
'AUDIENCE': 'cbwebreader-users',
'ISSUER': 'cbwebreader',
}
# Renderer formats no longer include a leading dot, so the URL pattern in
# cbreader/urls.py matches the dot separately. See cbreader/urls.py.
SWAGGER_USE_COMPAT_RENDERERS = False
# Folder scans run through comic.tasks.scan_directory. The immediate backend runs the task
# in the caller, which is enough today; swapping to a queue backend later changes only
# this block, not the call sites.
TASKS = {
'default': {
'BACKEND': os.getenv('TASK_BACKEND', 'django.tasks.backends.immediate.ImmediateBackend'),
},
} }
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend') FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
+4 -23
View File
@@ -1,30 +1,11 @@
from .base import * from .base import INSTALLED_APPS, MIDDLEWARE, SILK_ENABLED
INSTALLED_APPS = ( INSTALLED_APPS += ["silk"]
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
'silk',
"snowpenguin.django.recaptcha2",
'bootstrap4',
"comic",
"comic_auth",
)
MIDDLEWARE = [ MIDDLEWARE += [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
'silk.middleware.SilkyMiddleware', 'silk.middleware.SilkyMiddleware',
] ]
SILK_ENABLED = True SILK_ENABLED = True # noqa: F811
SILKY_PYTHON_PROFILER = True SILKY_PYTHON_PROFILER = True
-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'
+10 -9
View File
@@ -22,10 +22,10 @@ from django.views.generic import TemplateView
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
from rest_framework import permissions from rest_framework import permissions
from rest_framework_extensions.routers import ExtendedDefaultRouter from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from comic import rest, feeds # from rest_framework_extensions.routers import ExtendedDefaultRouter
from comic import auth_views, feeds, rest
schema_view = get_schema_view( schema_view = get_schema_view(
openapi.Info( openapi.Info(
@@ -39,12 +39,12 @@ schema_view = get_schema_view(
permission_classes=[permissions.AllowAny] permission_classes=[permissions.AllowAny]
) )
router = ExtendedDefaultRouter() router = DefaultRouter()
router.register(r'users', rest.UserViewSet) router.register(r'users', rest.UserViewSet)
router.register(r'browse', rest.BrowseViewSet, basename='browse') router.register(r'browse', rest.BrowseViewSet, basename='browse')
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail') router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
router.register(r'read', rest.ReadViewSet, basename='read')\ router.register(r'read', rest.ReadViewSet, basename='read')
.register(r'image', rest.ImageViewSet, basename='image', parents_query_lookups=['selector']) router.register(r'read/(?P<selector>[^/.]+)/image', rest.ImageViewSet, basename='image')
router.register(r'recent', rest.RecentComicsView, basename="recent") router.register(r'recent', rest.RecentComicsView, basename="recent")
router.register(r'history', rest.HistoryViewSet, basename='history') router.register(r'history', rest.HistoryViewSet, basename='history')
router.register(r'action', rest.ActionViewSet, basename='action') router.register(r'action', rest.ActionViewSet, basename='action')
@@ -56,11 +56,12 @@ router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup')
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path("feed/<user_selector>/", feeds.RecentComicsAPI()), path("feed/<user_selector>/", feeds.RecentComicsAPI()),
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), re_path(r'^swagger\.(?P<format>json|yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/token/', auth_views.CookieTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), 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('api/', include(router.urls)),
path("", path("",
TemplateView.as_view(template_name="application.html"), TemplateView.as_view(template_name="application.html"),
+1 -2
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
from django.contrib import admin from django.contrib import admin
from .models import Directory, ComicBook, ComicStatus, UserMisc from .models import ComicBook, ComicStatus, Directory, UserMisc
@admin.register(Directory) @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
+3 -3
View File
@@ -1,10 +1,10 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.db.models import Case, When, PositiveSmallIntegerField, F from django.db.models import Case, F, PositiveSmallIntegerField, QuerySet, When
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from comic.models import ComicBook, UserMisc, Directory from comic.models import ComicBook, Directory, UserMisc
class RecentComicsAPI(Feed): class RecentComicsAPI(Feed):
@@ -18,7 +18,7 @@ class RecentComicsAPI(Feed):
self.user = user_misc.user self.user = user_misc.user
return user_misc.user return user_misc.user
def items(self) -> ComicBook: def items(self) -> QuerySet[ComicBook]:
comics = ComicBook.objects.order_by("-date_added") comics = ComicBook.objects.order_by("-date_added")
comics = comics.annotate( comics = comics.annotate(
classification=Case( classification=Case(
View File
Whitespace-only changes.
+20 -13
View File
@@ -1,12 +1,11 @@
from typing import Optional, Union from pathlib import Path
from django.contrib.auth import get_user_model from django.conf import settings
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandParser from django.core.management.base import BaseCommand, CommandParser
from loguru import logger from loguru import logger
from comic.models import ComicBook, Directory from comic.models import ComicBook, Directory
from comic.processing import generate_directory from comic.processing import reconcile
class Command(BaseCommand): class Command(BaseCommand):
@@ -22,17 +21,25 @@ class Command(BaseCommand):
action='store_true', action='store_true',
help='Output to console', help='Output to console',
) )
parser.add_argument(
'--if-empty',
action='store_true',
help='Do nothing when the database already contains books, so it is safe to run on startup.',
)
def handle(self, *args, **options) -> None: def handle(self, *args, **options) -> None:
self.OUTPUT = options.get('out', False) self.OUTPUT = options.get('out', False)
if options.get('if_empty') and ComicBook.objects.exists():
logger.info('The database already contains books, so the startup scan is skipped.')
return
self.scan_directory() self.scan_directory()
def scan_directory(self, user: Optional[User] = None, directory: Optional[Directory] = None) -> None: def scan_directory(self, directory: Directory | None = None) -> None:
if not user: reconcile(directory)
user_model = get_user_model() path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
user = user_model.objects.first() if self.OUTPUT:
for item in generate_directory(user, directory): self.stdout.write(str(path))
item: Union[Directory, ComicBook] else:
if item.type == 'Directory': logger.info(path)
logger.info(item) for child in Directory.objects.filter(parent=directory):
self.scan_directory(user, item) self.scan_directory(child)
+1 -2
View File
@@ -7,8 +7,7 @@ import uuid
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from django.utils.timezone import utc utc = datetime.timezone.utc
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -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'),
),
]
+53 -43
View File
@@ -1,16 +1,13 @@
import io import io
import mimetypes
import uuid import uuid
import zipfile import zipfile
from functools import reduce from functools import reduce
from pathlib import Path from pathlib import Path
from typing import Optional, List, Union, Tuple, Final, IO from typing import IO, Final
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import fitz import pymupdf
import rarfile import rarfile
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile
@@ -19,7 +16,10 @@ from django.db.models import UniqueConstraint
from django_boost.models.fields import AutoOneToOneField from django_boost.models.fields import AutoOneToOneField
from imagekit.models import ProcessedImageField from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFill from imagekit.processors import ResizeToFill
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive from comic.errors import NotCompatibleArchive
if settings.UNRAR_TOOL: if settings.UNRAR_TOOL:
@@ -50,9 +50,13 @@ class Directory(models.Model):
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
constraints = [
models.UniqueConstraint(fields=['parent', 'name'], name='one_directory_name_per_parent'),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"Directory: {self.name}; {self.parent}"
return f"Directory: {self.name}: {self.parent}"
@property @property
def title(self) -> str: def title(self) -> str:
@@ -63,7 +67,7 @@ class Directory(models.Model):
return 'Directory' return 'Directory'
def generate_thumbnail(self) -> None: 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: if not book:
return return
if not book.thumbnail: if not book.thumbnail:
@@ -83,7 +87,7 @@ class Directory(models.Model):
return reduce(lambda x, y: Path(x, y), path_items) return reduce(lambda x, y: Path(x, y), path_items)
return Path(path_items[0]) return Path(path_items[0])
def get_path_items(self, path_items: Optional[List] = None) -> List[Path]: def get_path_items(self, path_items: list | None = None) -> list[Path]:
if path_items is None: if path_items is None:
path_items = [] path_items = []
path_items.append(self.name) path_items.append(self.name)
@@ -91,7 +95,7 @@ class Directory(models.Model):
self.parent.get_path_items(path_items) self.parent.get_path_items(path_items)
return path_items return path_items
def get_path_objects(self, path_items: Optional[List] = None) -> List["Directory"]: def get_path_objects(self, path_items: list | None = None) -> list[Directory]:
if path_items is None: if path_items is None:
path_items = [] path_items = []
path_items.append(self) path_items.append(self)
@@ -140,29 +144,43 @@ class ComicBook(models.Model):
return Path(base_dir, self.directory.get_path(), self.file_name) return Path(base_dir, self.directory.get_path(), self.file_name)
return Path(base_dir, self.file_name) return Path(base_dir, self.file_name)
def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]: def get_image(self, page: int) -> tuple[IO[bytes], str] | tuple[bool, bool]:
base_dir = settings.COMIC_BOOK_VOLUME if self.file_name.lower().endswith('.pdf'):
if self.directory: # noinspection PyUnresolvedReferences
archive_path = Path(base_dir, self.directory.path, self.file_name) doc = pymupdf.open(self.get_pdf())
page: pymupdf.Page = doc[page]
pix = page.get_pixmap()
mode: Final = "RGBA" if pix.alpha else "RGB"
# noinspection PyTypeChecker
pil_data = Image.frombytes(mode, (pix.width, pix.height), pix.samples)
img = io.BytesIO()
pil_data.save(img, format="PNG")
img.seek(0)
return img, "Image/PNG"
else: else:
archive_path = Path(base_dir, self.file_name) base_dir = settings.COMIC_BOOK_VOLUME
try: if self.directory:
archive = rarfile.RarFile(archive_path) archive_path = Path(base_dir, self.directory.path, self.file_name)
except rarfile.NotRarFile: else:
# pylint: disable=consider-using-with archive_path = Path(base_dir, self.file_name)
archive = zipfile.ZipFile(archive_path) try:
except zipfile.BadZipfile: archive = rarfile.RarFile(archive_path)
return False, False except rarfile.NotRarFile:
# pylint: disable=consider-using-with
archive = zipfile.ZipFile(archive_path)
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 return archive.open(file_name), file_mime
def generate_thumbnail_pdf(self, page_index: int = 0) -> Tuple[io.BytesIO, Image_type, str]: def generate_thumbnail_pdf(self, page_index: int = 0) -> tuple[io.BytesIO, Image_type, str]:
img, pil_data = self._get_pdf_image(page_index if page_index else 0) img, pil_data = self._get_pdf_image(page_index if page_index else 0)
return img, pil_data, 'Image/JPEG' return img, pil_data, 'Image/JPEG'
def generate_thumbnail_archive(self, page_index: int = 0) -> Union[Tuple[io.BytesIO, Image_type, str], def generate_thumbnail_archive(
Tuple[bool, bool, bool]]: self, page_index: int = 0
) -> tuple[io.BytesIO, Image_type, str] | tuple[bool, bool, bool]:
img, content_type, pil_data = False, False, False img, content_type, pil_data = False, False, False
if page_index: if page_index:
img, content_type = self.get_image(page_index) img, content_type = self.get_image(page_index)
@@ -195,9 +213,8 @@ class ComicBook(models.Model):
) )
self.save() self.save()
def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]: def _get_pdf_image(self, page_index: int) -> tuple[io.BytesIO, Image_type]:
# noinspection PyUnresolvedReferences doc = pymupdf.open(self.get_pdf())
doc = fitz.open(self.get_pdf())
page = doc[page_index] page = doc[page_index]
pix = page.get_pixmap() pix = page.get_pixmap()
mode: Final = "RGBA" if pix.alpha else "RGB" mode: Final = "RGBA" if pix.alpha else "RGB"
@@ -209,7 +226,7 @@ class ComicBook(models.Model):
return img, pil_data return img, pil_data
@staticmethod @staticmethod
def process_comic_book(comic_file_path: Path, directory: "Directory" = False) -> Union["ComicBook", Path]: def process_comic_book(comic_file_path: Path, directory: Directory = False) -> ComicBook | Path:
try: try:
book = ComicBook.objects.get(file_name=comic_file_path.name, version=0) book = ComicBook.objects.get(file_name=comic_file_path.name, version=0)
book.directory = directory book.directory = directory
@@ -227,7 +244,7 @@ class ComicBook(models.Model):
return comic_file_path return comic_file_path
if archive_type == 'archive': if archive_type == 'archive':
book.page_count = len(book.get_archive_files(archive)) book.page_count = len(get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
book.page_count = archive.page_count book.page_count = archive.page_count
@@ -239,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.directory.get_path(), self.file_name)
return Path(settings.COMIC_BOOK_VOLUME, self.file_name) return Path(settings.COMIC_BOOK_VOLUME, self.file_name)
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, fitz.Document], str]: def get_archive(self) -> tuple[rarfile.RarFile | zipfile.ZipFile | pymupdf.Document, str]:
archive_path = self.get_archive_path archive_path = self.get_archive_path
try: try:
return rarfile.RarFile(archive_path), 'archive' return rarfile.RarFile(archive_path), 'archive'
@@ -252,7 +269,7 @@ class ComicBook(models.Model):
try: try:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
return fitz.open(str(archive_path)), 'pdf' return pymupdf.open(str(archive_path)), 'pdf'
except RuntimeError: except RuntimeError:
pass pass
raise NotCompatibleArchive raise NotCompatibleArchive
@@ -260,17 +277,10 @@ class ComicBook(models.Model):
def get_page_count(self) -> int: def get_page_count(self) -> int:
archive, archive_type = self.get_archive() archive, archive_type = self.get_archive()
if archive_type == 'archive': if archive_type == 'archive':
return len(self.get_archive_files(archive)) return len(get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
return archive.page_count return archive.page_count
@staticmethod
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[Tuple[str, str]]:
return [
(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
if not x.endswith('/') and mimetypes.guess_type(x)[0] and not x.endswith('xml')
]
class ComicStatus(models.Model): class ComicStatus(models.Model):
user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE) user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE)
@@ -291,8 +301,8 @@ class ComicStatus(models.Model):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<ComicStatus:{self.user.username}:{self.comic.file_name}:{self.last_read_page}:" f"<ComicStatus: {self.user.username}: {self.comic.file_name}: {self.last_read_page}: "
f"{self.unread}:{self.finished}" f"{self.unread}: {self.finished}"
) )
+59 -58
View File
@@ -1,48 +1,59 @@
import mimetypes from collections.abc import Iterable
import zipfile
from itertools import chain
from pathlib import Path from pathlib import Path
from typing import NamedTuple, List, Optional, Union
import rarfile
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.base_user import AbstractBaseUser
from django.db.models import Count, Q, F, Case, When, PositiveSmallIntegerField, QuerySet, ExpressionWrapper, \ from django.db.models import (
IntegerField BooleanField,
Case,
Count,
ExpressionWrapper,
F,
IntegerField,
PositiveSmallIntegerField,
Q,
QuerySet,
Value,
When,
)
from django.db.models.functions import Coalesce
from comic import models from comic import models
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive from comic.errors import NotCompatibleArchive
def generate_directory(user: User, directory: Optional[models.Directory] = None) -> List[QuerySet]: 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 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(models.Directory.objects.filter(parent=directory), dir_path, directory)
clean_directories(dir_db_query, 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), total=Count('comicbook', distinct=True),
progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True, progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True,
comicbook__comicstatus__user=user), distinct=True), comicbook__comicstatus__user=user), distinct=True),
finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()), finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()),
unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()), unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()),
) )
files.extend(dir_db_query)
# Create Missing Status # A book with no status for this user is simply unread, so the annotations have to
new_status = [models.ComicStatus(comic=file, user=user) for file in # stand in for the missing row instead of assuming one exists.
file_db_query.exclude(comicstatus__in=models.ComicStatus.objects.filter( file_db_query = models.ComicBook.objects.filter(directory=directory).annotate(
comic__in=file_db_query, user=user))] progress=Coalesce(F('comicstatus__last_read_page') + 1, Value(0, output_field=IntegerField())),
models.ComicStatus.objects.bulk_create(new_status) finished=Coalesce(F('comicstatus__finished'), Value(False, output_field=BooleanField())),
unread=Coalesce(F('comicstatus__unread'), Value(True, output_field=BooleanField())),
file_db_query = file_db_query.annotate(
progress=F('comicstatus__last_read_page') + 1,
finished=F('comicstatus__finished'),
unread=F('comicstatus__unread'),
user=F('comicstatus__user'), user=F('comicstatus__user'),
classification=Case( classification=Case(
When(directory__isnull=True, then=models.Directory.Classification.C_G), When(directory__isnull=True, then=models.Directory.Classification.C_G),
@@ -51,67 +62,57 @@ def generate_directory(user: User, directory: Optional[models.Directory] = None)
) )
).filter(Q(user__isnull=True) | Q(user=user.id)) ).filter(Q(user__isnull=True) | Q(user=user.id))
files.extend(file_db_query) files = list(dir_db_query) + list(file_db_query)
for file in chain(file_db_query, dir_db_query):
if file.thumbnail and not Path(file.thumbnail.path).exists():
file.thumbnail.delete()
file.save()
files.sort(key=lambda x: x.title) files.sort(key=lambda x: x.title)
files.sort(key=lambda x: x.type, reverse=True) files.sort(key=lambda x: x.type, reverse=True)
return files return files
def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional[models.Directory] = None) -> None: def clean_directories(directories: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
dir_db_set = set(Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories) dir_db_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()) dir_list = {x for x in sorted(dir_path.glob('*')) if x.is_dir()}
# Create new directories db instances # Create new directories db instances
for new_directory in dir_list - dir_db_set: for new_directory in dir_list - dir_db_set:
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 # Remove stale db instances
for stale_directory in dir_db_set - dir_list: for stale_directory in dir_db_set - dir_list:
models.Directory.objects.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: User, dir_path: Path, directory: Optional[models.Directory] = None) -> None: def clean_files(files: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
file_list = set(x for x in sorted(dir_path.glob('*')) if x.is_file()) file_list = {x for x in sorted(dir_path.glob('*')) if x.is_file()}
files_db_set = set(Path(dir_path, x.file_name) for x in files) files_db_set = {Path(dir_path, x.file_name) for x in files}
# Parse new comics # Parse new comics
books_to_add = [] books_to_add = []
for new_comic in file_list - files_db_set: for new_comic in file_list - files_db_set:
if new_comic.suffix.lower() in settings.SUPPORTED_FILES: if new_comic.suffix.lower() in settings.SUPPORTED_FILES:
new_book = models.ComicBook(file_name=new_comic.name, directory=directory) new_book = models.ComicBook(file_name=new_comic.name, directory=directory)
archive, archive_type = new_book.get_archive()
try: try:
archive, archive_type = new_book.get_archive()
if archive_type == 'archive': if archive_type == 'archive':
new_book.page_count = len(get_archive_files(archive)) new_book.page_count = len(get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
new_book.page_count = archive.page_count new_book.page_count = archive.page_count
except NotCompatibleArchive: except NotCompatibleArchive:
# Unreadable/corrupt archive - index the file with no page count
pass pass
books_to_add.append(new_book) books_to_add.append(new_book)
models.ComicBook.objects.bulk_create(books_to_add)
status_to_add = [] # ignore_conflicts: a concurrent scan may have indexed the same file, and
for book in books_to_add: # (directory, file_name) is unique.
status_to_add.append(models.ComicStatus(user=user, comic=book)) models.ComicBook.objects.bulk_create(books_to_add, ignore_conflicts=True)
models.ComicStatus.objects.bulk_create(status_to_add)
# Remove stale comic instances # Remove stale comic instances
for stale_comic in files_db_set - file_list: for stale_comic in files_db_set - file_list:
models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete() models.ComicBook.objects.filter(file_name=stale_comic.name, directory=directory).delete()
class ArchiveFile(NamedTuple): def clean_thumbnails(items: Iterable[models.Directory | models.ComicBook]) -> None:
file_name: str for item in items:
mime_type: str if item.thumbnail and not Path(item.thumbnail.path).exists():
item.thumbnail.delete()
item.save()
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[ArchiveFile]:
return [
ArchiveFile(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
if not x.endswith('/') and mimetypes.guess_type(x)[0]
]
+128 -92
View File
@@ -1,26 +1,39 @@
import json
from collections.abc import Iterable
from http.client import HTTPResponse
from pathlib import Path from pathlib import Path
from typing import Union, Optional, Dict, Iterable, List
from uuid import UUID from uuid import UUID
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Case, When, F, PositiveSmallIntegerField, FileField, QuerySet from django.db.models import Case, F, FileField, PositiveSmallIntegerField, QuerySet, When
from django.http import FileResponse from django.http import FileResponse
from django.utils.crypto import get_random_string
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import 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.decorators import action
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from comic import models from comic import models, processing, tasks
from comic.processing import generate_directory
from comic.util import generate_breadcrumbs_from_path from comic.util import generate_breadcrumbs_from_path
def user_can_read(user: models.User, directory: models.Directory | None) -> bool:
"""Whether the user's classification allowance covers this book.
Books that are not inside a directory are treated as the highest rating, which
matches how the browse API reports them.
"""
misc, _ = models.UserMisc.objects.get_or_create(user=user)
classification = directory.classification if directory else models.Directory.Classification.C_18
return classification <= misc.allowed_to_read
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read', classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
source='usermisc') source='usermisc')
@@ -59,16 +72,15 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
""" """
target_user = get_object_or_404(User, id=pk) target_user = get_object_or_404(User, id=pk)
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid() and target_user.username == serializer.data['username']:
if target_user.username == serializer.data['username']: password = get_random_string(12)
password = User.objects.make_random_password() target_user.set_password(password)
target_user.set_password(password) target_user.save()
target_user.save() resp_serializer = self.get_serializer({
resp_serializer = self.get_serializer({ 'username': target_user.username,
'username': target_user.username, 'password': password
'password': password })
}) return Response(resp_serializer.data)
return Response(resp_serializer.data)
return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST) return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST)
@@ -87,7 +99,7 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
class BrowseFileField(serializers.FileField): 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: if not value:
return None return None
return Path(settings.MEDIA_URL, value.name).as_posix() return Path(settings.MEDIA_URL, value.name).as_posix()
@@ -111,34 +123,47 @@ class BreadcrumbSerializer(serializers.Serializer):
name = serializers.CharField() name = serializers.CharField()
class ScanSerializer(serializers.Serializer):
selector = serializers.UUIDField(required=False, allow_null=True)
class BrowseViewSet(viewsets.GenericViewSet): class BrowseViewSet(viewsets.GenericViewSet):
serializer_class = BrowseSerializer serializer_class = BrowseSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector' lookup_field = 'selector'
def get_queryset(self): def get_queryset(self) -> None:
return return
def list(self, request: Request) -> Response: 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) return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)}) @swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
def retrieve(self, request: Request, selector: UUID) -> Response: def retrieve(self, request: Request, selector: UUID) -> Response:
directory = models.Directory.objects.get(selector=selector) directory = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(generate_directory(request.user, directory), many=True) serializer = self.get_serializer(processing.list_directory(request.user, directory), many=True)
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema(request_body=ScanSerializer, responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
@action(methods=['post'], detail=False, serializer_class=ScanSerializer)
def scan(self, request: Request) -> Response:
"""Read one directory from disk, then return it. Null selector means the root."""
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
selector = serializer.validated_data.get('selector')
tasks.scan_directory.enqueue(str(selector) if selector else None)
directory = models.Directory.objects.filter(selector=selector).first() if selector else None
data = processing.list_directory(request.user, directory)
return Response(BrowseSerializer(data, many=True).data)
@swagger_auto_schema(responses={status.HTTP_200_OK: BreadcrumbSerializer(many=True)}) @swagger_auto_schema(responses={status.HTTP_200_OK: BreadcrumbSerializer(many=True)})
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer) @action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
def breadcrumbs(self, _request: Request, selector: UUID) -> Response: def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
queryset = [] queryset = []
comic = False comic = models.ComicBook.objects.filter(selector=selector).first()
try: directory = comic.directory if comic else get_object_or_404(models.Directory, selector=selector)
directory = models.Directory.objects.get(selector=selector)
except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector)
directory = comic.directory
for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)): for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
queryset.append({ queryset.append({
@@ -163,25 +188,17 @@ class GenerateThumbnailViewSet(viewsets.ViewSet):
@swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()})
def retrieve(self, _request: Request, selector: UUID) -> Response: def retrieve(self, _request: Request, selector: UUID) -> Response:
try: try:
directory = models.Directory.objects.get(selector=selector) target = models.Directory.objects.get(selector=selector)
if not directory.thumbnail:
directory.generate_thumbnail()
return Response(
self.serializer_class({
"selector": directory.selector,
"thumbnail": directory.thumbnail
}).data
)
except models.Directory.DoesNotExist: except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector) target = get_object_or_404(models.ComicBook, selector=selector)
if not comic.thumbnail: if not target.thumbnail:
comic.generate_thumbnail() target.generate_thumbnail()
return Response( return Response(
self.serializer_class({ self.serializer_class({
"selector": comic.selector, "selector": target.selector,
"thumbnail": comic.thumbnail "thumbnail": target.thumbnail
}).data }).data
) )
class DirectionSerializer(serializers.Serializer): class DirectionSerializer(serializers.Serializer):
@@ -211,9 +228,12 @@ class ReadViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector' lookup_field = 'selector'
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer(),
status.HTTP_403_FORBIDDEN: 'User below classification allowed'})
def retrieve(self, request: Request, selector: UUID) -> Response: def retrieve(self, request: Request, selector: UUID) -> Response:
comic = get_object_or_404(models.ComicBook, selector=selector) comic = get_object_or_404(models.ComicBook, selector=selector)
if not user_can_read(request.user, comic.directory):
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
_, _ = models.UserMisc.objects.get_or_create(user=request.user) _, _ = models.UserMisc.objects.get_or_create(user=request.user)
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user) comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name')) comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name'))
@@ -245,22 +265,22 @@ class ReadViewSet(viewsets.GenericViewSet):
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data', @swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
status.HTTP_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) @action(methods=['get'], detail=True)
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response]: def pdf(self, request: Request, selector: UUID) -> FileResponse | Response | HTTPResponse:
book = models.ComicBook.objects.get(selector=selector) book = get_object_or_404(models.ComicBook, selector=selector)
misc, _ = models.UserMisc.objects.get_or_create(user=request.user) if not user_can_read(request.user, book.directory):
try: return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
if book.directory.classification > misc.allowed_to_read: pdf_path = book.get_pdf()
return Response(status=400, data={'errors': 'Not allowed to read.'}) if not pdf_path.is_file():
except AttributeError: return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Comic file not found.'})
pass return FileResponse(open(pdf_path, 'rb'), content_type='application/pdf')
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
@action(methods=['get'], detail=True) @action(methods=['get'], detail=True)
def type(self, _request: Request, selector: UUID) -> Response: def type(self, _request: Request, selector: UUID) -> Response:
book = models.ComicBook.objects.get(selector=selector) book = get_object_or_404(models.ComicBook, selector=selector)
return Response({'type': book.file_name.split('.')[-1].lower()}) return Response({'type': book.file_name.split('.')[-1].lower()})
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer) @swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer)
@@ -270,15 +290,11 @@ class ReadViewSet(viewsets.GenericViewSet):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
comic_status, _ = models.ComicStatus.objects.annotate(page_count=F('comic__page_count')) \ comic = get_object_or_404(models.ComicBook, selector=selector)
.get_or_create(comic_id=selector, user=request.user) comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
comic_status.last_read_page = serializer.data['page'] comic_status.last_read_page = serializer.data['page']
comic_status.unread = False comic_status.unread = False
if comic_status.page_count - 1 == comic_status.last_read_page: comic_status.finished = comic.page_count > 0 and comic_status.last_read_page >= comic.page_count - 1
comic_status.finished = True
else:
comic_status.finished = False
comic_status.save() comic_status.save()
return Response(self.get_serializer(comic_status).data) return Response(self.get_serializer(comic_status).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -291,20 +307,30 @@ class PassthroughRenderer(renderers.BaseRenderer): # pylint: disable=too-few-pu
media_type = '*/*' media_type = '*/*'
format = '' 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: -> 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): class ImageViewSet(viewsets.ViewSet):
queryset = models.ComicBook.objects.all() queryset = models.ComicBook.objects.all()
lookup_field = 'page' 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"}) @swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response",
def retrieve(self, _request: Request, parent_lookup_selector: UUID, page: int) -> FileResponse: status.HTTP_403_FORBIDDEN: 'User below classification allowed',
book = models.ComicBook.objects.get(selector=parent_lookup_selector) 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) img, content = book.get_image(int(page) - 1)
if not img:
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Page could not be read.'})
self.renderer_classes[0].media_type = content self.renderer_classes[0].media_type = content
return FileResponse(img, content_type=content) return FileResponse(img, content_type=content)
@@ -432,7 +458,7 @@ class ActionViewSet(viewsets.GenericViewSet):
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user) obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
obj.unread = False obj.unread = False
obj.finished = True obj.finished = True
obj.last_read_page = comic.total_pages obj.last_read_page = max(comic.page_count - 1, 0)
status_to_update.append(obj) status_to_update.append(obj)
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page']) models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
return Response({'status': 'marked_read'}) return Response({'status': 'marked_read'})
@@ -464,7 +490,7 @@ class ActionViewSet(viewsets.GenericViewSet):
return Response(serializer.errors, return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
def get_comics(self, selectors: Iterable[Union[str, UUID]]) -> List[str]: def get_comics(self, selectors: Iterable[str | UUID]) -> list[str]:
data = set() data = set()
data = data.union( data = data.union(
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True))) set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
@@ -506,10 +532,10 @@ class PasswordResetSerializer(serializers.Serializer):
try: try:
validate_password(data) validate_password(data)
except ValidationError as err: except ValidationError as err:
raise serializers.ValidationError(err) raise serializers.ValidationError(err) from err
return data return data
def validate(self, attrs: Dict[str, str]) -> Dict[str, str]: def validate(self, attrs: dict[str, str]) -> dict[str, str]:
super().validate(attrs) super().validate(attrs)
if attrs['new_password'] != attrs['new_password_confirm']: if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError('New passwords do not match') raise serializers.ValidationError('New passwords do not match')
@@ -580,37 +606,47 @@ class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
permission_classes = [permissions.IsAdminUser] permission_classes = [permissions.IsAdminUser]
lookup_field = 'selector' lookup_field = 'selector'
@staticmethod
def _descendants(directory: models.Directory) -> list[models.Directory]:
"""The directory itself plus every directory below it."""
to_update = [directory]
to_visit = [directory]
while to_visit:
parent = to_visit.pop()
for child in models.Directory.objects.filter(parent=parent):
to_visit.append(child)
to_update.append(child)
return to_update
@swagger_auto_schema(responses={200: DirectorySerializer(many=True)}) @swagger_auto_schema(responses={200: DirectorySerializer(many=True)})
def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ
""" """
This will set the classification of a directory and all 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) main_parent = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
if serializer.is_valid(): to_update = self._descendants(main_parent)
main_parent.classification = serializer.data['classification'] for directory in to_update:
to_update = {main_parent} directory.classification = serializer.data['classification']
to_visit = {main_parent} models.Directory.objects.bulk_update(to_update, fields=['classification'])
return Response(self.get_serializer(to_update, many=True).data)
while to_visit:
parent = to_visit.pop()
for child in models.Directory.objects.filter(parent=parent):
child.classification = serializer.data['classification']
to_visit.add(child)
to_update.add(child)
models.Directory.objects.bulk_update(to_update, fields=['classification'])
data = models.Directory.objects.filter(directory__in=to_update)
response = self.get_serializer(data, many=True)
return Response(response.data)
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(responses={200: DirectorySerializer()})
def partial_update(self, request: Request, *args, **kwargs) -> Response: def partial_update(self, request: Request, *args, **kwargs) -> Response:
""" """
This will set the classification of a directory and none of its children. This will set the classification of a directory and none of its children.
""" """
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): 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 dataclasses import dataclass
from typing import Optional
from comic.models import ComicBook, Directory from comic.models import ComicBook, Directory
@@ -10,12 +9,12 @@ class Breadcrumb:
selector: str = '' 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()] output = [Breadcrumb()]
if directory: folders = directory.get_path_objects() if directory else []
folders = directory.get_path_objects()
else:
folders = []
for item in folders[::-1]: for item in folders[::-1]:
output.append( output.append(
Breadcrumb( Breadcrumb(
View File
Whitespace-only changes.
+28 -20
View File
@@ -1,64 +1,72 @@
version: "2.4"
services: services:
cbwebreader: cbwebreader:
build: ../
image: ajurna/cbwebreader image: ajurna/cbwebreader
env_file: .env env_file: .env
links:
- database
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
expose: expose:
- 8000 - 8000
volumes: volumes:
- ${COMIC_BOOK_VOLUME}:${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 - static_files:/static
- media_files:/media - media_files:/media
- .env:/src/.env - .env:/src/.env
command: /bin/bash entrypoint.sh command: sh entrypoint.sh
restart: unless-stopped
cbwebreader-cron: cbwebreader-cron:
build: ../
image: ajurna/cbwebreader image: ajurna/cbwebreader
env_file: .env env_file: .env
links:
- database
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} - ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- media_files:/media - media_files:/media
- .env:/src/.env - .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: database:
image: postgres:11.4-alpine image: postgres:16-alpine
expose: expose:
- 5432 - 5432
volumes: volumes:
- ./data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: 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 interval: 5s
timeout: 10s timeout: 10s
retries: 3 retries: 3
environment: environment:
- POSTGRES_USER=${DB_USER} POSTGRES_USER: ${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS} POSTGRES_PASSWORD: ${DB_PASS}
- POSTGRES_DB=${DB_DATABASE} POSTGRES_DB: ${DB_DATABASE}
restart: unless-stopped
nginx: nginx:
image: nginx image: nginx:1-alpine
volumes: volumes:
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- ./nginx.conf:/etc/nginx/conf.d/default.conf - ./nginx.conf:/etc/nginx/conf.d/default.conf
ports: ports:
- 1337:80 - "${WEB_PORT:-8337}:80"
depends_on: depends_on:
- cbwebreader cbwebreader:
condition: service_healthy
restart: unless-stopped
volumes: volumes:
static_files: static_files:
media_files: media_files:
postgres_data:
+8 -1
View File
@@ -6,6 +6,13 @@ server {
listen 80; listen 80;
# nginx compresses only text/html by default, so the bundle and CSS need explicit types.
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/wasm;
gzip_vary on;
location / { location / {
proxy_pass http://cbwebreader_django; proxy_pass http://cbwebreader_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -20,4 +27,4 @@ server {
alias /media/; alias /media/;
} }
} }
+38 -31
View File
@@ -1,65 +1,72 @@
version: "2.4"
services: services:
cbwebreader: cbwebreader:
build: . build: .
image: ajurna/cbwebreader
env_file: .env env_file: .env
links:
- database
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
expose: expose:
- 8000 - 8000
volumes: volumes:
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} # COMIC_BOOK_VOLUME is the path *inside* the container; the host path is
# - c:/comics:/comics # a separate variable so a Windows host path is not passed to Django.
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- .env:/src/.env - .env:/src/.env
command: /bin/bash /src/entrypoint.sh command: sh entrypoint.sh
restart: unless-stopped
# cbwebreader-cron: cbwebreader-cron:
# build: . build: .
# env_file: .env image: ajurna/cbwebreader
# links: env_file: .env
# - database depends_on:
# depends_on: database:
# database: condition: service_healthy
# condition: service_healthy volumes:
# volumes: - ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
# - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} - media_files:/media
# - media_files:/media - .env:/src/.env
# - .env:/src/.env # The image HEALTHCHECK probes the web port, which this service never serves.
# command: /bin/bash entrypoint-cron.sh healthcheck:
disable: true
command: sh entrypoint-cron.sh
restart: unless-stopped
database: database:
image: postgres:14-alpine image: postgres:16-alpine
expose: expose:
- 5432 - 5432
volumes: volumes:
- /var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: 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 interval: 5s
timeout: 10s timeout: 10s
retries: 3 retries: 3
environment: environment:
- POSTGRES_USER=${DB_USER} POSTGRES_USER: ${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS} POSTGRES_PASSWORD: ${DB_PASS}
- POSTGRES_DB=${DB_DATABASE} POSTGRES_DB: ${DB_DATABASE}
restart: unless-stopped
nginx: nginx:
image: nginx image: nginx:1-alpine
volumes: volumes:
- static_files:/static - static_files:/static
- media_files:/media - media_files:/media
- ./cbreader/settings/nginx.conf:/etc/nginx/conf.d/default.conf - ./compose/nginx.conf:/etc/nginx/conf.d/default.conf
ports: ports:
- 8337:80 - "${WEB_PORT:-8337}:80"
depends_on: depends_on:
- cbwebreader cbwebreader:
condition: service_healthy
restart: unless-stopped
volumes: volumes:
static_files: static_files:
media_files: media_files:
postgres_data:
+9 -2
View File
@@ -1,5 +1,12 @@
#!/usr/bin/env sh
set -e
python manage.py migrate --settings=cbreader.settings.base python manage.py migrate --settings=cbreader.settings.base
python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear # 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
gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application 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: {},
},
]
+6193 -10035
View File
File diff suppressed because it is too large. Load diff
+29 -45
View File
@@ -5,58 +5,42 @@
"scripts": { "scripts": {
"serve": "webpack-dev-server --config webpack.dev.js", "serve": "webpack-dev-server --config webpack.dev.js",
"build": "webpack --config webpack.prod.js", "build": "webpack --config webpack.prod.js",
"lint": "vue-cli-service lint" "lint": "eslint .",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.2", "@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.0.1", "@fortawesome/vue-fontawesome": "^3.3.3",
"axios": "^0.27.2", "axios": "^1.20.0",
"bootstrap": "^5.2.0", "bootstrap": "^5.3.8",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"jwt-decode": "^3.1.2", "jwt-decode": "^4.0.0",
"pdfvuer": "^2.0.1", "reveal.js": "^6.0.2",
"reveal.js": "^4.3.1",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"vue": "^3.2.13", "vue": "^3.5.44",
"vue-router": "^4.0.3", "vue-router": "^5.4.0",
"vue-toast-notification": "3.0", "vue-toast-notification": "^3.1.3",
"vuejs-paginate-next": "^1.0.2", "vuejs-paginate-next": "^1.0.2",
"vuex": "^4.0.0", "vuex": "^4.1.0"
"webpack": "^5.74.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.16", "@eslint/js": "^10.0.1",
"@babel/eslint-parser": "^7.12.16", "@vitejs/plugin-vue": "^6.0.10",
"@vue/cli-plugin-babel": "~5.0.0", "@vue/test-utils": "^2.5.1",
"@vue/cli-plugin-eslint": "~5.0.0", "css-loader": "^7.1.5",
"@vue/cli-plugin-router": "~5.0.0", "eslint": "^10.12.0",
"@vue/cli-plugin-vuex": "~5.0.0", "eslint-plugin-vue": "^10.11.1",
"@vue/cli-service": "~5.0.0", "jsdom": "^30.1.2",
"eslint": "^7.32.0", "mini-css-extract-plugin": "^2.10.2",
"eslint-plugin-vue": "^8.0.3", "style-loader": "^4.0.0",
"jshint": "^2.13.5", "terser-webpack-plugin": "^5.6.1",
"mini-css-extract-plugin": "^2.6.1", "vitest": "^5.0.4",
"terser-webpack-plugin": "^5.3.6", "vue-loader": "^17.4.2",
"webpack-bundle-analyzer": "^4.6.1", "webpack": "^5.111.1",
"webpack-cli": "^4.10.0", "webpack-bundle-tracker": "^3.2.4",
"webpack-bundle-tracker": "^1.6.0", "webpack-cli": "^7.2.3",
"style-loader": "^3.3.1", "webpack-dev-server": "^6.0.0"
"vue-loader": "^17.0.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
}, },
"browserslist": [ "browserslist": [
"defaults" "defaults"
+42 -29
View File
@@ -1,40 +1,53 @@
import axios from "axios"; import axios from "axios";
import router from "@/router"; import router from "@/router";
import store from "@/store"; import store from "@/store";
import jwtDecode from "jwt-decode";
async function get_access_token() { // Authentication is cookie based: the browser sends the HttpOnly access token
let access = jwtDecode(store.state.jwt.access) // automatically, so there is no Authorization header to set here.
let refresh = jwtDecode(store.state.jwt.refresh) const axios_jwt = axios.create({ withCredentials: true });
if (access.exp - Date.now()/1000 < 5) {
if (refresh.exp - Date.now()/1000 < 5) { // Add the CSRF header if a csrftoken cookie exists. Cookie authentication is
await router.push({name: 'login', params: { username: 'eduardo' }}) // protected against cross-site form posts by SameSite=Lax, so this is only used
return null // when the backend does set the cookie.
} else { axios_jwt.interceptors.request.use(function(config) {
return store.dispatch('refreshToken').then(() => {return store.state.jwt.access}) const csrfToken = document.cookie
} .split('; ')
} .find(row => row.startsWith('csrftoken='))
return store.state.jwt.access ?.split('=')[1];
if (csrfToken) {
config.headers['X-CSRFToken'] = csrfToken;
} }
const axios_jwt = axios.create(); return config;
});
axios_jwt.interceptors.request.use(async function (config) { // A 401 means the access cookie is no longer valid. Try to refresh it once; if
let access_token = await get_access_token().catch(() => { // that fails the session is gone, so log out and send the user to the login page.
if (router.currentRoute.value.fullPath.includes('login')){ axios_jwt.interceptors.response.use(
router.push({name: 'login'}) (response) => response,
}else { async (error) => {
router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }}) const config = error.config;
if (error.response?.status === 401 && config && !config._retried && !config.url.includes('/token/')) {
config._retried = true;
try {
await store.dispatch('refreshToken');
return await axios_jwt.request(config);
} catch {
store.commit('logOut');
router.push({
name: 'login',
query: {
next: config.url,
error: 'Your session has expired. Please log in again.'
}
});
}
} }
}) return Promise.reject(error);
config.headers = {
Authorization: "Bearer " + access_token
} }
return config );
}, function (error) {
// Do something with request error
return Promise.reject(error);
});
export default axios_jwt export default axios_jwt
+4 -3
View File
@@ -23,9 +23,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<caption> <h2>Reading History</h2>
<h2>Reading History</h2>
</caption>
</div> </div>
<div class="row"> <div class="row">
<table class="table table-striped table-bordered"> <table class="table table-striped table-bordered">
@@ -138,6 +136,9 @@ export default {
mounted() { mounted() {
this.updateComicList() this.updateComicList()
}, },
beforeUnmount() {
clearTimeout(this.timeout)
},
} }
</script> </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_unread')"><font-awesome-icon icon='book' />Mark Un-read</a></li>
<li><a class="dropdown-item" @click="markAll('mark_read')"><font-awesome-icon icon='book-open' />Mark read</a></li> <li><a class="dropdown-item" @click="markAll('mark_read')"><font-awesome-icon icon='book-open' />Mark read</a></li>
</ul> </ul>
<button type="button" class="btn btn-outline-secondary" :disabled="scanning" title="Read this folder from disk again" @click="scanFolder">
<font-awesome-icon icon='refresh' />{{ scanning ? 'Scanning…' : 'Scan' }}
</button>
</div> </div>
</div> </div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto mt-1" > <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto mt-1" >
@@ -49,7 +52,8 @@ export default {
filter_read: false, filter_read: false,
filter_unread: false filter_unread: false
}, },
loading: true loading: true,
scanning: false
}}, }},
props: { props: {
selector: String selector: String
@@ -68,6 +72,18 @@ export default {
}) })
.catch((error) => {console.log(error)}) .catch((error) => {console.log(error)})
}, },
scanFolder () {
this.scanning = true
api.post('/api/browse/scan/', { selector: this.selector || null })
.then(response => {
this.comics = response.data
this.scanning = false
})
.catch((error) => {
console.log(error)
this.scanning = false
})
},
markPreviousRead (selector) { markPreviousRead (selector) {
let selectors = [] let selectors = []
this.comics.every((item) => { this.comics.every((item) => {
+436 -67
View File
@@ -1,19 +1,74 @@
<template> <template>
<div class="reveal" id="comic_box" ref="comic_box" > <div class="reveal" :class="{ continuous: mode === 'scroll' }" id="comic_box" ref="comic_box">
<div id="slides_div" class="slides" ref="slides"> <div id="slides_div" class="slides" ref="slides">
<section class="" v-for="page in pages" :key="page" :data-menu-title="page" hidden> <section
<img :data-src="'/api/read/' + selector + '/image/' + page + '/'" class="w-100" :alt="page"> 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> </section>
</div> </div>
<div class="sentinel" ref="sentinel" v-if="mode === 'scroll' && rendered < pages"></div>
</div> </div>
<div class="row navButtons pb-2"> <div class="row navButtons pb-2">
<comic-paginate <div class="col-auto">
v-model="paginate_page" <div class="btn-group btn-group-sm" role="group" aria-label="Reading mode">
:page_count="pages" <button
@setPage="this.setPage" type="button"
@prevComic="prevComic" class="btn btn-outline-secondary"
@nextComic="nextComic" :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 class="col-auto">
<button
type="button"
class="btn btn-sm btn-outline-secondary"
:class="{ active: show_shortcuts }"
:aria-pressed="show_shortcuts ? 'true' : 'false'"
@click="show_shortcuts = !show_shortcuts"
>?</button>
</div>
</div>
<div class="shortcuts" v-if="show_shortcuts" role="dialog" aria-label="Keyboard shortcuts">
<div class="shortcuts-head">
<strong>Keyboard shortcuts</strong>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="show_shortcuts = false">Close</button>
</div>
<table>
<tbody>
<tr v-for="shortcut in shortcuts" :key="shortcut.keys">
<td><kbd>{{ shortcut.keys }}</kbd></td>
<td>{{ shortcut.action }}</td>
</tr>
</tbody>
</table>
</div> </div>
</template> </template>
@@ -21,9 +76,17 @@
<script> <script>
import Reveal from "reveal.js"; import Reveal from "reveal.js";
import api from "@/api"; import api from "@/api";
import * as Hammer from 'hammerjs' import Hammer from 'hammerjs'
import ComicPaginate from "@/components/ComicPaginate"; import ComicPaginate from "@/components/ComicPaginate";
// How many pages are rendered at a time in continuous mode. The window grows as the
// reader approaches the end of what has already been rendered.
const WINDOW_SIZE = 20;
// Held keys repeat, so a key press is handled immediately and then ignored for this
// long. The page write is already debounced by queue_save, so nothing is sent per key.
const KEY_THROTTLE_MS = 100;
export default { export default {
name: "TheComicReader", name: "TheComicReader",
components: {ComicPaginate}, components: {ComicPaginate},
@@ -31,23 +94,79 @@ export default {
return { return {
current_page: 0, current_page: 0,
paginate_page: 1, paginate_page: 1,
inview: new Set(),
deck: null, deck: null,
title: '', title: '',
prev_comic: {}, prev_comic: {},
next_comic: {}, next_comic: {},
pages: 1, pages: 1,
rendered: 0,
visible: new Map(),
page_observer: null,
growth_observer: null,
save_timeout: null,
key_locked: false,
key_timeout: null,
show_shortcuts: false,
shortcuts: [
{ keys: '← / →', action: 'Previous / next page' },
{ keys: '↑ / ↓', action: 'Scroll within the page' },
{ keys: 'Space', action: 'Next page' },
{ keys: 'Shift + Space', action: 'Previous page' },
{ keys: 'n / p', action: 'Next / previous page' },
{ keys: 'Home / End', action: 'First / last page' },
{ keys: 'm', action: 'Switch reading mode' },
{ keys: '?', action: 'Show or hide this list' },
{ keys: 'Esc', action: 'Hide this list' },
],
} }
}, },
props: { props: {
selector: String selector: String
}, },
computed: {
mode () {
return this.$store.state.reader_mode
},
set_read_url () {
return '/api/read/' + this.selector + '/set_page/'
},
visible_pages () {
if (this.mode === 'paged') {
return Array.from({length: this.pages}, (_item, index) => index + 1)
}
return Array.from({length: Math.min(this.rendered, this.pages)}, (_item, index) => index + 1)
}
},
methods: { methods: {
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()){ if (this.deck.isFirstSlide()){
this.prevComic() this.prevComic()
} else { } else {
this.current_page -= 1 this.current_page -= 1
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
// Pages can be taller than the viewport, so show the top of the new page instead
// of keeping the scroll offset left over from the previous one.
this.scroll_to_page(this.current_page + 1, behavior)
} }
}, },
prevComic(){ prevComic(){
@@ -62,59 +181,179 @@ export default {
params: {selector: this.next_comic.selector} 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()){ if (this.deck.isLastSlide()){
this.nextComic() this.nextComic()
} else { } else {
this.current_page += 1 this.current_page += 1
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
this.scroll_to_page(this.current_page + 1, behavior)
} }
}, },
setPage(pageNum){ setPage(pageNum){
if (this.mode === 'scroll') {
const page = Math.min(pageNum, this.pages)
this.rendered = Math.max(this.rendered, page)
this.current_page = page - 1
this.$nextTick(() => {
this.observe_pages()
this.scroll_to_page(page)
})
return
}
if (!this.deck) {
return
}
this.current_page = pageNum-1 this.current_page = pageNum-1
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
}, },
keyPressDebounce(e){ scroll_to_page (page, behavior = 'smooth') {
clearTimeout(this.key_timeout) const section = this.$refs.slides.querySelector('section[data-page="' + page + '"]')
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50) if (section) {
section.scrollIntoView({behavior: behavior, block: 'start'})
}
}, },
keyPress(e) { keyPress (e) {
if (e.key === 'ArrowRight') { // Alt+Left is the browser's back button and Ctrl/Cmd+Arrow moves a caret in a
this.nextPage() // form control, so only a bare arrow key pages.
} else if (e.key === 'ArrowLeft') { if (e.ctrlKey || e.metaKey || e.altKey) {
this.prevPage() return
} else if (e.key === 'ArrowUp') {
window.scrollTo({
top: window.scrollY-window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
} else if (e.key === 'ArrowDown') {
window.scrollTo({
top: window.scrollY+window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
} }
}
},
watch: {
'current_page' (new_page) {
this.paginate_page = new_page + 1
}
},
mounted () {
const set_read_url = '/api/read/' + this.selector + '/set_page/'
let comic_data_url = '/api/read/' + this.selector + '/'
window.addEventListener('keyup', this.keyPressDebounce)
api.get(comic_data_url)
.then(response => {
this.title = response.data.title
this.current_page = response.data.last_read_page
this.prev_comic = response.data.prev_comic
this.next_comic = response.data.next_comic
this.pages = response.data.pages
// These only show or hide the shortcut list, so they still work when a control
// has focus.
if (e.key === 'Escape') {
this.show_shortcuts = false
return
}
if (e.key === '?') {
this.show_shortcuts = !this.show_shortcuts
return
}
// The reader listens on window, so a key pressed while a control has focus would
// otherwise page as well as activate that control.
if (e.target && e.target.closest && e.target.closest('input, select, textarea, button')) {
return
}
if (this.key_locked) {
return
}
this.key_locked = true
this.key_timeout = setTimeout(() => { this.key_locked = false }, KEY_THROTTLE_MS)
// Left and Right change pages. Up and Down are left to the browser's default
// scrolling: the page is taller than the viewport, and intercepting them here would
// double up with the native scroll. In continuous mode the observer records whichever
// page is topmost, so scrolling inside a page does not change the saved page.
const key = e.key.toLowerCase()
switch (key) {
case 'arrowright':
case 'n':
this.nextPage('auto')
return
case 'arrowleft':
case 'p':
this.prevPage('auto')
return
case ' ':
// Space would otherwise scroll the page as well as turning it.
e.preventDefault?.()
if (e.shiftKey) {
this.prevPage('auto')
} else {
this.nextPage('auto')
}
return
case 'home':
case 'end':
// Same for Home/End, which the browser would scroll to the page edges.
e.preventDefault?.()
this.setPage(key === 'home' ? 1 : this.pages)
return
case 'm':
this.setMode(this.mode === 'paged' ? 'scroll' : 'paged')
return
}
},
// Continuous mode: reveal is not used for visibility here, so the page in view is
// worked out from what is actually intersecting the viewport.
start_scroll () {
this.visible.clear()
this.inview.clear()
this.page_observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
this.visible.set(entry.target, entry.boundingClientRect.top)
this.inview.add(Number(entry.target.dataset.page))
} else {
this.visible.delete(entry.target)
this.inview.delete(Number(entry.target.dataset.page))
}
}
if (this.visible.size === 0) {
return
}
const top = [...this.visible.entries()].sort((a, b) => a[1] - b[1])[0][0]
const page = Number(top.dataset.page)
if (page && page !== this.current_page + 1) {
this.current_page = page - 1
this.queue_save(page - 1)
}
}, { threshold: 0 })
this.growth_observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.extend_window()
}
}, { rootMargin: '1200px 0px' })
this.observe_pages()
if (this.$refs.sentinel) {
this.growth_observer.observe(this.$refs.sentinel)
}
this.scroll_to_page(this.current_page + 1)
},
observe_pages () {
this.$refs.slides.querySelectorAll('section[data-page]').forEach((section) => {
this.page_observer.observe(section)
})
},
extend_window () {
if (this.rendered >= this.pages) {
return
}
this.rendered = Math.min(this.pages, this.rendered + WINDOW_SIZE)
this.$nextTick(() => this.observe_pages())
},
// One write per burst of scrolling rather than one per intersection.
queue_save (page) {
clearTimeout(this.save_timeout)
this.save_timeout = setTimeout(() => {
api.put(this.set_read_url, {page: page})
}, 500)
},
start_paged () {
this.deck = Reveal(this.$refs.comic_box) this.deck = Reveal(this.$refs.comic_box)
this.deck.initialize({ this.deck.initialize({
controls: false, controls: false,
@@ -130,29 +369,99 @@ export default {
plugins: [ ] plugins: [ ]
}).then(() => { }).then(() => {
this.deck.slide(this.current_page) this.deck.slide(this.current_page)
api.put(set_read_url, {page: this.current_page}) this.slideChanged = (event) => {
this.deck.on( 'slidechanged', (event) => {
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'}) 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.$store.commit('setReaderMode', mode)
this.hammertime.on('swipeleft', (_e, self=this) => { this.teardown()
self.nextPage() await this.$nextTick()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
if (mode === 'scroll') {
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
await this.$nextTick()
this.start_scroll()
} else {
this.start_paged()
}
},
teardown () {
clearTimeout(this.save_timeout)
this.inview.clear()
if (this.page_observer) {
this.page_observer.disconnect()
this.page_observer = null
}
if (this.growth_observer) {
this.growth_observer.disconnect()
this.growth_observer = null
}
this.visible.clear()
if (this.deck) {
if (this.slideChanged) {
this.deck.off('slidechanged', this.slideChanged)
}
try {
this.deck.destroy()
} catch (e) {
console.log(e)
}
this.deck = null
}
}
},
watch: {
'current_page' (new_page) {
this.paginate_page = new_page + 1
}
},
mounted () {
let comic_data_url = '/api/read/' + this.selector + '/'
window.addEventListener('keydown', this.keyPress)
api.get(comic_data_url)
.then(response => {
this.title = response.data.title
this.current_page = response.data.last_read_page
this.prev_comic = response.data.prev_comic
this.next_comic = response.data.next_comic
this.pages = response.data.pages
if (this.mode === 'scroll') {
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
return this.$nextTick().then(() => this.start_scroll())
}
return this.$nextTick().then(() => this.start_paged())
})
.then(() => {
api.put(this.set_read_url, {page: this.current_page})
}) })
.catch((error) => {console.log(error)}) .catch((error) => {console.log(error)})
this.hammertime = new Hammer(this.$refs.comic_box, {})
this.hammertime.on('swipeleft', (_e, self=this) => {
self.nextPage()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
}, },
beforeUnmount() { beforeUnmount() {
window.removeEventListener('keyup', this.keyPressDebounce) window.removeEventListener('keydown', this.keyPress)
clearTimeout(this.key_timeout)
this.teardown()
try { try {
this.hammertime.off('swipeleft') this.hammertime.off('swipeleft')
this.hammertime.off('swiperight') this.hammertime.off('swiperight')
@@ -179,4 +488,64 @@ export default {
section { section {
padding-bottom: 60px; padding-bottom: 60px;
} }
.shortcuts {
position: fixed;
right: 12px;
bottom: 64px;
max-width: 340px;
padding: 10px 12px;
border: 1px solid #adb5bd;
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
font-size: 0.85rem;
z-index: 20;
}
.shortcuts-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.shortcuts td {
padding: 2px 8px 2px 0;
vertical-align: top;
}
.shortcuts kbd {
font-family: inherit;
white-space: nowrap;
}
/* reveal.css is deliberately not loaded, so visibility is the `hidden` attribute and
reveal's own transition classes do nothing. A transition cannot run across
display:none -> block, so paged mode uses an animation on the slide that becomes
present; continuous mode uses a transition driven by the IntersectionObserver. */
.reveal section.present img {
animation: page-fade 0.3s ease;
}
.continuous img {
opacity: 0;
transition: opacity 0.3s ease;
}
.continuous img.shown {
opacity: 1;
}
@keyframes page-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.reveal section.present img,
.continuous img {
animation: none;
transition: none;
}
}
</style> </style>
+14 -2
View File
@@ -6,7 +6,8 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <!-- Show these links only when user is authenticated -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-if="isAuthenticated">
<li class="nav-item"> <li class="nav-item">
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link> <router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
</li> </li>
@@ -26,6 +27,12 @@
<a class="nav-link" @click="logout">Log Out</a> <a class="nav-link" @click="logout">Log Out</a>
</li> </li>
</ul> </ul>
<!-- Show login link when user is not authenticated -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-else>
<li class="nav-item">
<router-link :to="{name: 'login'}" class="nav-link">Log In</router-link>
</li>
</ul>
</div> </div>
</div> </div>
</nav> </nav>
@@ -42,9 +49,14 @@ export default {
visible: false visible: false
} }
}, },
computed: {
isAuthenticated() {
return this.$store.getters.isAuthenticated;
}
},
methods: { methods: {
logout () { logout () {
store.commit('logOut') store.dispatch('logout')
router.push({name: 'login'}) router.push({name: 'login'})
} }
} }
-170
View File
@@ -1,170 +0,0 @@
<template>
<div class="container" ref="pdfContainer">
<div class="row w-100 pb-5 mb-5" v-if="loaded">
<pdf :src="pdfdata" :page="page" ref="pdfWindow" :resize="true">
<template v-slot:loading>
loading content here...
</template>
</pdf>
</div>
</div>
<div class="row navButtons pb-2">
<comic-paginate
v-model="page"
:page_count="numPages"
@setPage="setPage"
@prevComic="prevComic"
@nextComic="nextComic"
/>
</div>
</template>
<script>
import pdfvuer from 'pdfvuer'
import api from "@/api";
import * as Hammer from 'hammerjs'
import ComicPaginate from "@/components/ComicPaginate";
export default {
name: "ThePdfReader",
components: {
ComicPaginate,
pdf: pdfvuer
},
data () {
return {
page: 1,
numPages: 0,
pdfdata: undefined,
errors: [],
scale: 'page-width',
loaded: false,
key_timeout: null,
hammertime: null,
next_comic: {},
prev_comic: {}
}
},
props: {
selector: String
},
computed: {
},
mounted () {
this.getPdf()
window.addEventListener('keyup', this.keyPressDebounce)
},
beforeUnmount() {
window.removeEventListener('keyup', this.keyPressDebounce)
},
watch: {
},
methods: {
getPdf () {
let comic_data_url = '/api/read/' + this.selector + '/'
api.get(comic_data_url)
.then(response => {
let parameter = {
url: '/api/read/' + this.selector + '/pdf/',
httpHeaders: { Authorization: 'Bearer ' + this.$store.state.jwt.access },
withCredentials: true,
}
this.pdfdata = pdfvuer.createLoadingTask(parameter);
this.pdfdata.then(pdf => {
this.numPages = pdf.numPages;
this.loaded = true
this.page = response.data.last_read_page+1
this.setReadPage(this.page)
this.next_comic = response.data.next_comic
this.prev_comic = response.data.prev_comic
this.hammertime = new Hammer(this.$refs.pdfContainer, {})
this.hammertime.on('swipeleft', (_e, self=this) => {
self.nextPage()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
}).catch(e => {console.log(e)});
})
},
prevComic(){
this.$router.push({
name: this.prev_comic.route,
params: {selector: this.prev_comic.selector}
})
},
nextComic(){
this.$router.push({
name: this.next_comic.route,
params: {selector: this.next_comic.selector}
})
},
nextPage () {
if (this.page < this.numPages){
this.page += 1
this.setReadPage(this.page)
} else {
this.nextComic()
}
},
prevPage() {
if (this.page > 1){
this.page -= 1
this.setReadPage(this.page)
} else {
this.prevComic()
}
},
setPage(num) {
this.page = num
this.setReadPage(this.page)
},
setReadPage(num){
this.$refs.pdfContainer.scrollIntoView()
let payload = {
page: num-1
}
api.put('/api/read/'+ this.selector +'/set_page/', payload)
},
keyPressDebounce(e){
clearTimeout(this.key_timeout)
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
},
keyPress(e) {
if (e.key === 'ArrowRight') {
this.nextPage()
} else if (e.key === 'ArrowLeft') {
this.prevPage()
} else if (e.key === 'ArrowUp') {
window.scrollTo({
top: window.scrollY-window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
} else if (e.key === 'ArrowDown') {
window.scrollTo({
top: window.scrollY+window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
}
}
}
}
</script>
<style scoped>
.navButtons {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: 0;
z-index: 1030;
width: auto;
cursor: pointer;
}
</style>
+3 -2
View File
@@ -23,7 +23,6 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<caption>
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2> <h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
Mark selected issues as: Mark selected issues as:
<select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected"> <select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
@@ -31,7 +30,6 @@
<option value="mark_read">Read</option> <option value="mark_read">Read</option>
<option value="mark_unread">Un-Read</option> <option value="mark_unread">Un-Read</option>
</select> </select>
</caption>
</div> </div>
<div class="row"> <div class="row">
<table class="table table-striped table-bordered"> <table class="table table-striped table-bordered">
@@ -177,6 +175,9 @@ export default {
this.feed_id = response.data.feed_id this.feed_id = response.data.feed_id
}) })
}, },
beforeUnmount() {
clearTimeout(this.timeout)
},
} }
</script> </script>
+9 -4
View File
@@ -15,15 +15,20 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
/* import specific icons */ /* import specific icons */
import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck} from '@fortawesome/free-solid-svg-icons' import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh} from '@fortawesome/free-solid-svg-icons'
library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck) library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh)
import router from './router' import router from './router'
import store from './store' import store from './store'
Vue.createApp(App) const app = Vue.createApp(App)
.use(ToastPlugin) .use(ToastPlugin)
.use(store) .use(store)
.use(router) .use(router)
.component('font-awesome-icon', FontAwesomeIcon) .component('font-awesome-icon', FontAwesomeIcon)
.mount('#app')
// Validate the cookie before the first navigation, so the router guards see the
// real session instead of whatever is left in localStorage.
store.dispatch('restoreSession').finally(() => {
app.mount('#app')
})
+33 -6
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import store from '@/store'
const ReadView = () => import('@/views/ReadView') const ReadView = () => import('@/views/ReadView')
const RecentView = () => import('@/views/RecentView') const RecentView = () => import('@/views/RecentView')
@@ -8,6 +9,26 @@ const UserView = () => import('@/views/UserView')
const LoginView = () => import('@/views/LoginView') const LoginView = () => import('@/views/LoginView')
const HistoryView = () => import('@/views/HistoryView') const HistoryView = () => import('@/views/HistoryView')
// Navigation guard to check if user is authenticated
function requireAuth(to) {
if (!store.getters.isAuthenticated) {
return {
name: 'login',
query: { next: to.fullPath, error: 'Please log in to access this page' }
};
}
}
// Navigation guard to check if user is admin
function requireAdmin(to) {
if (!store.getters.isAuthenticated || !store.getters.is_superuser) {
return {
name: 'login',
query: { next: to.fullPath, error: 'Admin access required' }
};
}
}
const routes = [ const routes = [
{ {
path: '/', path: '/',
@@ -20,13 +41,15 @@ const routes = [
path: '/browse/:selector?', path: '/browse/:selector?',
name: 'browse', name: 'browse',
component: BrowseView, component: BrowseView,
props: true props: true,
beforeEnter: requireAuth
}, },
{ {
path: '/read/:selector', path: '/read/:selector',
name: 'read', name: 'read',
component: ReadView, component: ReadView,
props: true props: true,
beforeEnter: requireAuth
}, },
{ {
path: '/login', path: '/login',
@@ -36,23 +59,27 @@ const routes = [
{ {
path: '/recent', path: '/recent',
name: 'recent', name: 'recent',
component: RecentView component: RecentView,
beforeEnter: requireAuth
}, },
{ {
path: '/history', path: '/history',
name: 'history', name: 'history',
component: HistoryView component: HistoryView,
beforeEnter: requireAuth
}, },
{ {
path: '/account', path: '/account',
name: 'account', name: 'account',
component: AccountView component: AccountView,
beforeEnter: requireAuth
}, },
{ {
path: '/user/:userid?', path: '/user/:userid?',
name: 'user', name: 'user',
component: UserView, component: UserView,
props: true props: true,
beforeEnter: requireAdmin
}, },
{ {
path: '/about', path: '/about',
+96 -57
View File
@@ -1,17 +1,11 @@
import { createStore } from 'vuex' import { createStore } from 'vuex'
import axios from 'axios'
import jwtDecode from 'jwt-decode'
import {useToast} from "vue-toast-notification"; import {useToast} from "vue-toast-notification";
import router from "@/router"; import router from "@/router";
import api from "@/api"; import api from "@/api";
function get_jwt_from_storage(){ // Tokens live in HttpOnly cookies set by the backend, so JavaScript cannot read
try { // them. Only the user profile is kept in localStorage, to avoid a blank page
return JSON.parse(localStorage.getItem('t')) // before the first request has been made.
} catch {
return null
}
}
function get_user_from_storage(){ function get_user_from_storage(){
try { try {
return JSON.parse(localStorage.getItem('u')) return JSON.parse(localStorage.getItem('u'))
@@ -20,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({ export default createStore({
state: { state: {
jwt: get_jwt_from_storage(),
filters: {}, filters: {},
user: get_user_from_storage(), user: get_user_from_storage(),
reader_mode: get_reader_mode_from_storage(),
classifications: [ classifications: [
{label: 'G', value: '0'}, {label: 'G', value: '0'},
{label: 'PG', value: '1'}, {label: 'PG', value: '1'},
@@ -40,23 +50,27 @@ export default createStore({
} else { } else {
return state.user.is_superuser return state.user.is_superuser
} }
},
isAuthenticated (state) {
return state.user !== null
} }
}, },
mutations: { mutations: {
updateToken(state, newToken){
localStorage.setItem('t', JSON.stringify(newToken));
state.jwt = newToken;
},
logOut(state){ logOut(state){
localStorage.removeItem('t');
localStorage.removeItem('u') localStorage.removeItem('u')
state.jwt = null;
state.user = null state.user = null
}, },
updateUser(state, userData){ updateUser(state, userData){
localStorage.setItem('u', JSON.stringify(userData)); localStorage.setItem('u', JSON.stringify(userData));
state.user = userData state.user = userData
}, },
setReaderMode(state, mode){
if (mode !== 'paged' && mode !== 'scroll') {
return
}
localStorage.setItem('reader_mode', mode);
state.reader_mode = mode
},
}, },
actions: { actions: {
obtainToken(context, {username, password}){ obtainToken(context, {username, password}){
@@ -64,52 +78,77 @@ export default createStore({
username: username, username: username,
password: password password: password
} }
axios.post('/api/token/', payload) // The response body is empty: the backend stores the tokens as HttpOnly cookies.
.then((response)=>{ api.post('/api/token/', payload)
context.commit('updateToken', response.data); .then(() => api.get('/api/account'))
api.get('/api/account').then(response => { .then((response) => {
context.commit('updateUser', response.data) context.commit('updateUser', response.data)
}) if ('next' in router.currentRoute.value.query) {
if ('next' in router.currentRoute.value.query) { router.push(router.currentRoute.value.query.next)
router.push(router.currentRoute.value.query.next) } else {
} else { router.push('browse')
router.push('/') }
} })
.catch((error) => {
}) const $toast = useToast();
.catch((error)=>{ const data = error.response?.data ?? {};
// console.log(error); if (data.detail) {
const $toast = useToast(); $toast.error(data.detail, {position:'top'});
$toast.error(error.response.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(){ refreshToken(){
const payload = { if (!this.state.user) {
refresh: this.state.jwt.refresh return Promise.reject(new Error('Not logged in'));
} }
return axios.post('/api/token/refresh/', payload)
.then((response)=>{ // The refresh token is read from the cookie by the backend.
this.commit('updateToken', response.data) return api.post('/api/token/refresh/', {})
}) .then((response) => response.data)
.catch((error)=>{ .catch((error) => {
console.log(error) console.error('Token refresh failed:', error);
// router.push({name: 'login', query: {area: 'store'}}) this.commit('logOut');
}) router.push(session_expired_query(router.currentRoute.value.fullPath));
return Promise.reject(error);
});
}, },
inspectToken(){ // Check that the cookie still identifies the user stored in localStorage.
const token = this.state.jwt; restoreSession(){
if(token){ if (!this.state.user) {
const decoded = jwtDecode(token); return Promise.resolve(null);
const exp = decoded.exp
const orig_iat = decoded.iat
if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){
this.dispatch('refreshToken')
} else if (exp -(Date.now()/1000) < 1800){
// DO NOTHING, DO NOT REFRESH
} else {
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
}
} }
return api.get('/api/account')
.then((response) => {
this.commit('updateUser', response.data);
return response.data;
})
.catch((error) => {
if (error.response?.status === 401) {
this.commit('logOut');
router.push({ name: 'login' });
}
return null;
});
},
logout(){
const wasLoggedIn = this.state.user !== null;
this.commit('logOut');
if (!wasLoggedIn) {
return Promise.resolve(null);
}
return api.post('/api/token/blacklist/', {})
.catch((error) => {
console.error('Error blacklisting token:', error);
});
} }
}, },
modules: { modules: {
+20 -2
View File
@@ -3,7 +3,12 @@
<div class="row" v-if="!initialSetupRequired"> <div class="row" v-if="!initialSetupRequired">
<div class="col col-lg-4" /> <div class="col col-lg-4" />
<div class="col col-lg-4" id="login-col"> <div class="col col-lg-4" id="login-col">
<form @submit="login"> <!-- Display error message if present -->
<div class="alert alert-danger" v-if="errorMessage">
{{ errorMessage }}
</div>
<form @submit="login" v-on:submit.prevent="onSubmit">
<label class="form-label" for="username">Username</label> <label class="form-label" for="username">Username</label>
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" /> <input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
<div class="form-text" id="loginFormControlInputHelpInline">Please enter your username</div> <div class="form-text" id="loginFormControlInputHelpInline">Please enter your username</div>
@@ -34,7 +39,8 @@ export default {
username: '', username: '',
password: '', password: '',
password_alert: false, password_alert: false,
initialSetupRequired: false initialSetupRequired: false,
errorMessage: ''
} }
}, },
methods: { methods: {
@@ -43,11 +49,23 @@ export default {
} }
}, },
mounted() { mounted() {
// Check for error message in route query params
if (this.$route.query.error) {
this.errorMessage = this.$route.query.error;
}
// Check if initial setup is required
axios.get('/api/initial_setup/required/').then(response => { axios.get('/api/initial_setup/required/').then(response => {
if (response.data.required){ if (response.data.required){
this.initialSetupRequired = true this.initialSetupRequired = true
} }
}) })
},
// Clear error message when route changes
watch: {
'$route'(to) {
this.errorMessage = to.query.error || '';
}
} }
} }
</script> </script>
+6 -20
View File
@@ -1,52 +1,38 @@
<template> <template>
<the-breadcrumbs :selector="selector" /> <the-breadcrumbs :selector="selector" />
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" /> <the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
<the-pdf-reader :selector="selector" v-if="pdf_loaded" :key="selector" />
</template> </template>
<script> <script>
import TheBreadcrumbs from "@/components/TheBreadcrumbs"; import TheBreadcrumbs from "@/components/TheBreadcrumbs";
import TheComicReader from "@/components/TheComicReader"; import TheComicReader from "@/components/TheComicReader";
import api from "@/api"; import api from "@/api";
import ThePdfReader from "@/components/ThePdfReader";
export default { export default {
name: "ReadView", name: "ReadView",
components: {ThePdfReader, TheComicReader, TheBreadcrumbs}, components: {TheComicReader, TheBreadcrumbs},
props: { props: {
selector: String selector: String
}, },
data () { data () {
return { return {
comic_data: {},
comic_loaded: false, comic_loaded: false,
pdf_loaded: false
} }
}, },
methods: { methods: {
updateType() { checkComicExists() {
let comic_data_url = '/api/read/' + this.selector + '/type/' api.get('/api/read/' + this.selector + '/type/')
api.get(comic_data_url) .then(() => {
.then(response => {
if (response.data.type === 'pdf'){
this.pdf_loaded = true
this.comic_loaded = false
} else {
this.comic_loaded = true this.comic_loaded = true
this.pdf_loaded = false
}
}) })
.catch((error) => {console.log(error)}) .catch((error) => {console.log(error)})
} }
}, },
mounted () { mounted () {
this.updateType() this.checkComicExists()
},
beforeUpdate() {
this.updateType()
} }
} }
</script> </script>
<style scoped> <style scoped>
</style> </style>
+1 -2
View File
@@ -16,7 +16,6 @@ import UserEdit from "@/components/UserEdit";
import alertMessages from "@/components/AlertMessages"; import alertMessages from "@/components/AlertMessages";
import AddUser from "@/components/AddUser"; import AddUser from "@/components/AddUser";
import router from "@/router"; import router from "@/router";
import store from "@/store";
const default_crumbs = [ const default_crumbs = [
{id: 0, selector: '', name: 'Home'}, {id: 0, selector: '', name: 'Home'},
@@ -68,7 +67,7 @@ export default {
} }
}, },
watch: { watch: {
$route(to, from) { $route() {
this.updateUsers() this.updateUsers()
this.crumbs = [...default_crumbs] this.crumbs = [...default_crumbs]
if (this.userid){ 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'] })
})
})
+520
View File
@@ -0,0 +1,520 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { reactive } from 'vue'
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
const router = vi.hoisted(() => ({ push: vi.fn() }))
const deck = vi.hoisted(() => ({
initialize: vi.fn(() => Promise.resolve()),
slide: vi.fn(),
on: vi.fn(),
off: vi.fn(),
destroy: vi.fn(),
isFirstSlide: vi.fn(() => false),
isLastSlide: vi.fn(() => false),
}))
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
const reveal = vi.hoisted(() => vi.fn(() => deck))
const store = vi.hoisted(() => ({
state: { reader_mode: 'paged' },
commit: vi.fn(),
}))
const observers = []
// jsdom has no IntersectionObserver, so the tests drive the callbacks by hand.
class FakeIntersectionObserver {
constructor(callback, options) {
this.callback = callback
this.options = options
this.targets = []
this.disconnected = false
observers.push(this)
}
observe(target) {
if (!this.targets.includes(target)) this.targets.push(target)
}
unobserve(target) { this.targets = this.targets.filter((item) => item !== target) }
disconnect() { this.disconnected = true; this.targets = [] }
fire(entries) { this.callback(entries, this) }
}
vi.mock('@/api', () => ({ default: api }))
vi.mock('reveal.js', () => ({ default: reveal }))
vi.mock('hammerjs', () => ({ default: vi.fn(function () { return hammertime }) }))
import TheComicReader from '@/components/TheComicReader.vue'
const COMIC = {
title: 'Alpha',
last_read_page: 2,
pages: 5,
prev_comic: { route: 'browse', selector: 'p1' },
next_comic: { route: 'browse', selector: 'n1' },
}
const mounted = []
function mountReader() {
const wrapper = mount(TheComicReader, {
props: { selector: 'a1' },
global: {
mocks: { $router: router, $store: store },
stubs: { 'comic-paginate': true },
},
})
mounted.push(wrapper)
return wrapper
}
beforeEach(() => {
vi.useFakeTimers()
HTMLElement.prototype.scrollIntoView = vi.fn()
api.get.mockReset()
api.put.mockReset()
api.get.mockResolvedValue({ data: { ...COMIC } })
api.put.mockResolvedValue({ data: {} })
router.push.mockReset()
store.state = reactive({ reader_mode: 'paged' })
store.commit.mockReset()
store.commit.mockImplementation((name, value) => {
if (name === 'setReaderMode' && (value === 'paged' || value === 'scroll')) {
store.state.reader_mode = value
}
})
observers.length = 0
globalThis.IntersectionObserver = FakeIntersectionObserver
reveal.mockClear()
for (const fn of Object.values(deck)) {
if (typeof fn === 'function') fn.mockClear?.()
}
for (const fn of Object.values(hammertime)) fn.mockClear()
})
afterEach(() => {
mounted.forEach((wrapper) => {
if (!wrapper.vm?.isUnmounted) wrapper.unmount()
})
mounted.length = 0
})
describe('TheComicReader', () => {
it('loads the book, starts the deck and records the page it resumed on', async () => {
const wrapper = mountReader()
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/read/a1/')
expect(deck.initialize).toHaveBeenCalled()
expect(wrapper.vm.current_page).toBe(2)
expect(deck.slide).toHaveBeenCalledWith(2)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 2 })
})
it('keeps the page indicator in step with the deck', async () => {
const wrapper = mountReader()
await flushPromises()
wrapper.vm.setPage(4)
expect(deck.slide).toHaveBeenCalledWith(3)
await flushPromises()
expect(wrapper.vm.paginate_page).toBe(4)
})
it('acts on the first arrow key and ignores held keys until the throttle expires', async () => {
const wrapper = mountReader()
await flushPromises()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
expect(wrapper.vm.current_page).toBe(3)
expect(deck.slide).toHaveBeenCalledTimes(2)
vi.advanceTimersByTime(100)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
expect(wrapper.vm.current_page).toBe(4)
expect(deck.slide).toHaveBeenCalledTimes(3)
})
it('moves to the next book when the last slide is reached', async () => {
const wrapper = mountReader()
await flushPromises()
deck.isLastSlide.mockReturnValueOnce(true)
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'n1' } })
})
it('moves to the previous book when the first slide is reached', async () => {
const wrapper = mountReader()
await flushPromises()
deck.isFirstSlide.mockReturnValueOnce(true)
wrapper.vm.keyPress({ key: 'ArrowLeft' })
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'p1' } })
})
it('records the page again whenever the visible slide changes', async () => {
mountReader()
await flushPromises()
const handler = deck.on.mock.calls[0][1]
api.put.mockClear()
handler({ indexh: 4 })
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
})
it('removes every listener and the deck when it is unmounted', async () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const removeSpy = vi.spyOn(window, 'removeEventListener')
const wrapper = mountReader()
await flushPromises()
expect(addSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
expect(deck.destroy).toHaveBeenCalled()
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
expect(hammertime.off).toHaveBeenCalledWith('swiperight')
expect(hammertime.off).toHaveBeenCalledWith('tap')
addSpy.mockRestore()
removeSpy.mockRestore()
})
it('leaves arrow keys with a modifier to the browser', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowLeft', altKey: true })
expect(deck.slide).not.toHaveBeenCalled()
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(wrapper.vm.current_page).toBe(3)
})
it('ignores arrow keys pressed while a form control has focus', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('input') })
expect(deck.slide).not.toHaveBeenCalled()
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('button') })
expect(deck.slide).not.toHaveBeenCalled()
})
it('leaves Up and Down to the browser so they scroll within the page', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
api.put.mockClear()
wrapper.vm.keyPress({ key: 'ArrowDown' })
wrapper.vm.keyPress({ key: 'ArrowUp' })
// Nothing is intercepted, so the native scroll is not doubled up and the recorded
// page stays where the reader actually is.
expect(wrapper.vm.current_page).toBe(2)
expect(deck.slide).not.toHaveBeenCalled()
expect(api.put).not.toHaveBeenCalled()
})
it('shows the shortcut list with ? and hides it again', async () => {
const wrapper = mountReader()
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(false)
wrapper.vm.keyPress({ key: '?' })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(true)
wrapper.vm.keyPress({ key: '?' })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(false)
})
it('hides the shortcut list with Escape even when a control has focus', async () => {
const wrapper = mountReader()
await flushPromises()
wrapper.vm.keyPress({ key: '?' })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(true)
wrapper.vm.keyPress({ key: 'Escape', target: document.createElement('input') })
await flushPromises()
expect(wrapper.find('.shortcuts').exists()).toBe(false)
})
it('toggles the shortcut list from the ? button', async () => {
const wrapper = mountReader()
await flushPromises()
const button = wrapper.find('button[aria-pressed]')
await button.trigger('click')
expect(wrapper.find('.shortcuts').exists()).toBe(true)
expect(button.attributes('aria-pressed')).toBe('true')
})
it('pages with Space and Shift+Space', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
const preventDefault = vi.fn()
wrapper.vm.keyPress({ key: ' ', preventDefault })
expect(preventDefault).toHaveBeenCalled()
expect(deck.slide).toHaveBeenCalledWith(3)
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: ' ', shiftKey: true, preventDefault })
expect(deck.slide).toHaveBeenCalledWith(2)
})
it('pages with n and p whatever the case', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'n' })
expect(deck.slide).toHaveBeenCalledWith(3)
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'P' })
expect(deck.slide).toHaveBeenCalledWith(2)
})
it('jumps to the first and last page with Home and End', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
const preventDefault = vi.fn()
wrapper.vm.keyPress({ key: 'Home', preventDefault })
expect(preventDefault).toHaveBeenCalled()
expect(deck.slide).toHaveBeenCalledWith(0)
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'End', preventDefault })
expect(deck.slide).toHaveBeenCalledWith(4)
})
it('switches reading mode with m', async () => {
const wrapper = mountReader()
await flushPromises()
wrapper.vm.keyPress({ key: 'm' })
await flushPromises()
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'scroll')
expect(wrapper.vm.mode).toBe('scroll')
})
it('shows the top of the page when changing pages in paged mode', async () => {
const wrapper = mountReader()
await flushPromises()
const scrollIntoView = HTMLElement.prototype.scrollIntoView
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' })
})
it('does not move the deck when a key arrives before reveal has initialized', async () => {
const wrapper = mountReader()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(deck.slide).not.toHaveBeenCalled()
await flushPromises()
expect(deck.initialize).toHaveBeenCalled()
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(wrapper.vm.current_page).toBe(3)
expect(deck.slide).toHaveBeenCalledWith(3)
})
})
describe('TheComicReader in continuous mode', () => {
function mountContinuous(pageCount) {
store.state.reader_mode = 'scroll'
api.get.mockResolvedValue({ data: { ...COMIC, pages: pageCount } })
return mountReader()
}
function growthObserver() {
return observers.find((observer) => observer.options.rootMargin)
}
function pageObserver() {
return observers.find((observer) => !observer.options.rootMargin)
}
it('renders only the first window of pages and does not start a deck', async () => {
const wrapper = mountContinuous(45)
await flushPromises()
expect(wrapper.vm.rendered).toBe(20)
expect(wrapper.findAll('section[data-page]')).toHaveLength(20)
expect(reveal).not.toHaveBeenCalled()
})
it('renders far enough to include the page it resumed on', async () => {
store.state.reader_mode = 'scroll'
api.get.mockResolvedValue({ data: { ...COMIC, last_read_page: 30, pages: 45 } })
const wrapper = mountReader()
await flushPromises()
expect(wrapper.vm.rendered).toBe(31)
expect(wrapper.findAll('section[data-page]')).toHaveLength(31)
})
it('grows the window when the reader reaches the end of what is rendered', async () => {
const wrapper = mountContinuous(45)
await flushPromises()
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(wrapper.vm.rendered).toBe(40)
expect(wrapper.findAll('section[data-page]')).toHaveLength(40)
expect(pageObserver().targets).toHaveLength(40)
})
it('does not grow past the last page', async () => {
const wrapper = mountContinuous(25)
await flushPromises()
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(wrapper.vm.rendered).toBe(25)
expect(wrapper.find('.sentinel').exists()).toBe(false)
})
it('records the page at the top of the viewport', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
pageObserver().fire([
{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 10 } },
{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 400 } },
])
expect(wrapper.vm.current_page).toBe(4)
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
})
it('keeps the recorded page while scrolling inside a page taller than the viewport', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 0 } }])
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
// Scrolling down inside that page leaves it topmost, so nothing is written.
api.put.mockClear()
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: -300 } }])
vi.advanceTimersByTime(500)
expect(wrapper.vm.current_page).toBe(4)
expect(api.put).not.toHaveBeenCalled()
// The recorded page only changes when the next page becomes the topmost one.
observer.fire([
{ isIntersecting: false, target: sections[4].element },
{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 0 } },
])
vi.advanceTimersByTime(500)
expect(wrapper.vm.current_page).toBe(5)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 5 })
})
it('writes once for a burst of scrolling rather than once per intersection', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 400 } }])
observer.fire([{ isIntersecting: true, target: sections[6].element, boundingClientRect: { top: 0 } }])
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledTimes(1)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 6 })
})
it('marks the page in view as shown so it fades in', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(sections[2].find('img').classes()).toContain('shown')
expect(sections[0].find('img').classes()).not.toContain('shown')
observer.fire([{ isIntersecting: false, target: sections[2].element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(sections[2].find('img').classes()).not.toContain('shown')
})
it('scrolls to a page chosen from the pager, rendering it first if it is outside the window', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
const target = wrapper.findAll('section[data-page]')[19].element
const spy = vi.spyOn(target, 'scrollIntoView')
wrapper.vm.setPage(20)
await flushPromises()
expect(wrapper.vm.rendered).toBe(20)
expect(spy).toHaveBeenCalled()
})
it('disconnects both observers when it is unmounted', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
wrapper.unmount()
expect(observers.every((observer) => observer.disconnected)).toBe(true)
})
it('commits the mode change and rebuilds the deck when switching back to one page at a time', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
await wrapper.vm.setMode('paged')
await flushPromises()
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'paged')
expect(observers.every((observer) => observer.disconnected)).toBe(true)
expect(reveal).toHaveBeenCalled()
expect(wrapper.findAll('section[data-page]')).toHaveLength(30)
})
})
+107
View File
@@ -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()]
},
})
+11 -11
View File
@@ -3,6 +3,11 @@ const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker'); const BundleTracker = require('webpack-bundle-tracker');
const webpack = require('webpack') const webpack = require('webpack')
// The dev server port. Keep this and the publicPath in sync - django-webpack-loader
// builds the script URL from the publicPath written into webpack-stats.json.
const DEV_PORT = process.env.DEV_PORT || 8001
const DEV_PUBLIC_PATH = `http://localhost:${DEV_PORT}/`
module.exports = () => { module.exports = () => {
return { return {
@@ -11,6 +16,7 @@ module.exports = () => {
entry: path.resolve(__dirname, './src/main.js'), entry: path.resolve(__dirname, './src/main.js'),
output: { output: {
path: path.resolve(__dirname, './dist/bundles/'), path: path.resolve(__dirname, './dist/bundles/'),
publicPath: DEV_PUBLIC_PATH,
}, },
module: { module: {
rules: [ rules: [
@@ -18,14 +24,6 @@ module.exports = () => {
test: /\.vue$/, test: /\.vue$/,
use: 'vue-loader' use: 'vue-loader'
}, },
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{ {
test: /\.css$/, test: /\.css$/,
use: [ use: [
@@ -45,8 +43,9 @@ module.exports = () => {
plugins: [ plugins: [
new VueLoaderPlugin(), new VueLoaderPlugin(),
new BundleTracker({ new BundleTracker({
filename: './webpack-stats.json', filename: 'webpack-stats.json',
publicPath: 'http://localhost:8080/' path: path.resolve(__dirname, './'),
publicPath: DEV_PUBLIC_PATH
}), }),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL), 'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
@@ -54,10 +53,11 @@ module.exports = () => {
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }), new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }),
], ],
devServer: { devServer: {
port: DEV_PORT,
headers: { headers: {
"Access-Control-Allow-Origin":"*" "Access-Control-Allow-Origin":"*"
}, },
hot: true, hot: true,
} }
}; };
} }
+5 -11
View File
@@ -2,7 +2,6 @@ const path = require('path')
const { VueLoaderPlugin } = require('vue-loader') const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker'); const BundleTracker = require('webpack-bundle-tracker');
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const webpack = require('webpack') const webpack = require('webpack')
@@ -12,7 +11,9 @@ module.exports = (env = {}) => {
return { return {
mode: 'production', 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'), entry: path.resolve(__dirname, './src/main.js'),
output: { output: {
path: path.resolve(__dirname, './dist/bundles/'), path: path.resolve(__dirname, './dist/bundles/'),
@@ -23,14 +24,6 @@ module.exports = (env = {}) => {
test: /\.vue$/, test: /\.vue$/,
use: 'vue-loader' use: 'vue-loader'
}, },
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{ {
test: /\.css$/, test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"], use: [MiniCssExtractPlugin.loader, "css-loader"],
@@ -47,7 +40,8 @@ module.exports = (env = {}) => {
plugins: [ plugins: [
new VueLoaderPlugin(), new VueLoaderPlugin(),
new BundleTracker({ new BundleTracker({
filename: './webpack-stats.json', filename: 'webpack-stats.json',
path: path.resolve(__dirname, './'),
publicPath: '/static/bundles/', publicPath: '/static/bundles/',
integrity: true integrity: true
}), }),
Generated
-2179
View File
File diff suppressed because it is too large. Load diff
+65 -47
View File
@@ -1,55 +1,73 @@
[tool.black] [project]
line_length = 119
[tool.poetry]
name = "cbwebreader" name = "cbwebreader"
version = "1.1.2" version = "2.0.0"
description = "CBR/Z Web Reader" description = "CBR/Z Web Reader"
readme = "README.md"
requires-python = ">=3.14"
authors = ["ajurna <ajurna@gmail.com>"] authors = ["ajurna <ajurna@gmail.com>"]
license = "Creative Commons Attribution-ShareAlike 4.0 International License" license = "Creative Commons Attribution-ShareAlike 4.0 International License"
[tool.poetry.dependencies] dependencies = [
python = "^3.10" "dj-database-url>=3.1.2",
Django = "4.1.2" "django>=6.1.2",
gunicorn = "^20.0.4" "django-boost>=2.2",
dj-database-url = "^1.0.0" "django-cors-headers>=4.9.0",
python-dotenv = "^0.21.0" "django-csp>=4.0",
loguru = "^0.6.0" "django-extensions>=4.1",
django-silk = "^5.0.0" "django-filter>=26.2",
mysqlclient = "^2.0.1" "django-imagekit>=6.1.1",
psycopg2 = "^2.8.6" "django-permissions-policy>=4.34.0",
rarfile = "^4.0" "django-silk>=5.6.0",
django-extensions = "^3.2.1" "django-webpack-loader>=3.2.4",
Pillow = "^9.3.0" "djangorestframework>=3.18.3",
django-imagekit = "^4.0.2" "djangorestframework-simplejwt>=5.5.1",
PyMuPDF = "~1.20.2" "drf-yasg>=1.21.18",
django-bootstrap4 = "^22.1" "gunicorn>=26.2.0",
django-csp = "^3.7" "loguru>=0.7.3",
django-boost = "^2.1" "pillow>=12.3.0",
django-sri = "^0.5.0" "psycopg2-binary>=2.9.13",
django-permissions-policy = "^4.9.0" "pymupdf>=1.28.2",
djangorestframework = "^3.13.1" "python-dotenv>=1.2.4",
django-filter = "^22.1" "rarfile>=4.5",
django-cors-headers = "^3.13.0" ]
djangorestframework-simplejwt = "^5.2.0"
django-webpack-loader = "^1.6.0"
drf-yasg = "^1.20.0"
drf-extensions = "^0.7.1"
flake8 = "^5.0.4"
flake8-annotations = "^2.9.1"
[tool.poetry.dev-dependencies] [dependency-groups]
mypy = "^0.971" dev = [
Werkzeug = "^2.2" "coverage>=7.13.5",
pyOpenSSL = "^22.0.0" "ipython>=9.13.0",
ipython = "^8.4.0" "mypy>=2",
coverage = "^6.2" "pre-commit>=4.6",
pre-commit = "^2.20.0" "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.poetry.group.dev.dependencies] [tool.ruff]
pylint = "^2.15.0" target-version = "py314"
pylint-django = "^2.5.3" line-length = 120
extend-exclude = [
"*/migrations/*",
"cbreader/settings",
"frontend",
]
[build-system] [tool.ruff.lint]
requires = ["poetry-core>=1.0.0"] select = ["E", "F", "W", "I", "ANN", "B", "C4", "SIM", "UP"]
build-backend = "poetry.core.masonry.api" # 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
-66
View File
@@ -1,66 +0,0 @@
asgiref==3.5.2 ; python_version >= "3.10" and python_version < "4.0"
attrs==22.1.0 ; python_version >= "3.10" and python_version < "4.0"
autopep8==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
beautifulsoup4==4.11.1 ; python_version >= "3.10" and python_version < "4.0"
certifi==2022.9.24 ; python_version >= "3.10" and python_version < "4"
charset-normalizer==2.1.1 ; python_version >= "3.10" and python_version < "4"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
coreapi==2.3.3 ; python_version >= "3.10" and python_version < "4.0"
coreschema==0.0.4 ; python_version >= "3.10" and python_version < "4.0"
dj-database-url==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
django-appconf==1.0.5 ; python_version >= "3.10" and python_version < "4.0"
django-boost==2.1 ; python_version >= "3.10" and python_version < "4.0"
django-bootstrap4==22.2 ; python_version >= "3.10" and python_version < "4.0"
django-cors-headers==3.13.0 ; python_version >= "3.10" and python_version < "4.0"
django-csp==3.7 ; python_version >= "3.10" and python_version < "4.0"
django-extensions==3.2.1 ; python_version >= "3.10" and python_version < "4.0"
django-filter==22.1 ; python_version >= "3.10" and python_version < "4.0"
django-imagekit==4.1.0 ; python_version >= "3.10" and python_version < "4.0"
django-permissions-policy==4.13.0 ; python_version >= "3.10" and python_version < "4.0"
django-silk==5.0.2 ; python_version >= "3.10" and python_version < "4.0"
django-sri==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
django-webpack-loader==1.7.0 ; python_version >= "3.10" and python_version < "4.0"
django==4.1.2 ; python_version >= "3.10" and python_version < "4.0"
djangorestframework-simplejwt==5.2.2 ; python_version >= "3.10" and python_version < "4.0"
djangorestframework==3.14.0 ; python_version >= "3.10" and python_version < "4.0"
drf-extensions==0.7.1 ; python_version >= "3.10" and python_version < "4.0"
drf-yasg==1.21.4 ; python_version >= "3.10" and python_version < "4.0"
flake8-annotations==2.9.1 ; python_version >= "3.10" and python_version < "4.0"
flake8==5.0.4 ; python_version >= "3.10" and python_version < "4.0"
gprof2dot==2022.7.29 ; python_version >= "3.10" and python_version < "4.0"
gunicorn==20.1.0 ; python_version >= "3.10" and python_version < "4.0"
idna==3.4 ; python_version >= "3.10" and python_version < "4"
inflection==0.5.1 ; python_version >= "3.10" and python_version < "4.0"
itypes==1.2.0 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
loguru==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==2.1.1 ; python_version >= "3.10" and python_version < "4.0"
mccabe==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
mysqlclient==2.1.1 ; python_version >= "3.10" and python_version < "4.0"
packaging==21.3 ; python_version >= "3.10" and python_version < "4.0"
pilkit==2.0 ; python_version >= "3.10" and python_version < "4.0"
pillow==9.3.0 ; python_version >= "3.10" and python_version < "4.0"
psycopg2==2.9.5 ; python_version >= "3.10" and python_version < "4.0"
pycodestyle==2.9.1 ; python_version >= "3.10" and python_version < "4.0"
pyflakes==2.5.0 ; python_version >= "3.10" and python_version < "4.0"
pyjwt==2.6.0 ; python_version >= "3.10" and python_version < "4.0"
pymupdf==1.20.2 ; python_version >= "3.10" and python_version < "4.0"
pyparsing==3.0.9 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
python-dotenv==0.21.0 ; python_version >= "3.10" and python_version < "4.0"
pytz==2022.6 ; python_version >= "3.10" and python_version < "4.0"
rarfile==4.0 ; python_version >= "3.10" and python_version < "4.0"
requests==2.28.1 ; python_version >= "3.10" and python_version < "4"
ruamel-yaml-clib==0.2.7 ; platform_python_implementation == "CPython" and python_version < "3.11" and python_version >= "3.10"
ruamel-yaml==0.17.21 ; python_version >= "3.10" and python_version < "4.0"
setuptools==65.5.1 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
soupsieve==2.3.2.post1 ; python_version >= "3.10" and python_version < "4.0"
sqlparse==0.4.3 ; python_version >= "3.10" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.10" and python_version < "4.0"
tzdata==2022.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
ua-parser==0.16.1 ; python_version >= "3.10" and python_version < "4.0"
uritemplate==4.1.1 ; python_version >= "3.10" and python_version < "4.0"
urllib3==1.26.12 ; python_version >= "3.10" and python_version < "4"
user-agents==2.2.0 ; python_version >= "3.10" and python_version < "4.0"
win32-setctime==1.1.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
-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
+1506
View File
File diff suppressed because it is too large. Load diff