mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 18:46:53 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c12e19be | ||
|
|
80566e657e | ||
|
|
494b64621e | ||
|
|
a9e9bfb480 | ||
|
|
77a80dd322 | ||
|
|
cc557543c5 | ||
|
|
ce809ed1a9 | ||
|
|
4d0ba750ad | ||
|
|
6bcdf89fdc | ||
|
|
79c0647cc4 | ||
|
|
a7e9bd0743 | ||
|
|
1148f4072b | ||
|
|
147a3a54ef | ||
|
|
9b9db755e6 | ||
|
|
1bbe0071a0 | ||
|
|
91729f91dc | ||
|
|
67b2d9756a | ||
|
|
3ac2f8698c | ||
|
|
9aaa0f1c9f | ||
|
|
65dbe8e18d | ||
|
|
08a3c74a31 | ||
|
|
e5086ec653 | ||
|
|
dd5817419b | ||
|
|
b01eb60eeb | ||
|
|
306b237b01 | ||
|
|
a7cb857c00 | ||
|
|
0adfba1275 | ||
|
|
708df71220 | ||
|
|
871f930727 | ||
|
|
f7c698cd64 | ||
|
|
9e514354c8 | ||
|
|
3ae5b6b23b | ||
|
|
50d46ab835 | ||
|
|
40bd6f7e7f | ||
|
|
d46517745e | ||
|
|
a7206bb7f8 | ||
|
|
df0caf709e | ||
|
|
7049f3fa3b | ||
|
|
a540cb7efd | ||
|
|
7531e5b1fc | ||
|
|
8e5654db11 | ||
|
|
4e45374365 | ||
|
|
c303bca9d5 | ||
|
|
4d20e6042a | ||
|
|
3a3fd3c560 | ||
|
|
53e75ae660 | ||
|
|
c3f2cb94aa | ||
|
|
dad9c78344 | ||
|
|
9e361381ba | ||
|
|
be293bf90f | ||
|
|
bfa768405b | ||
|
|
95f3900089 | ||
|
|
cd5ac1ce56 | ||
|
|
d71a4e6f8d | ||
|
|
b39342f751 | ||
|
|
c115d0bb82 | ||
|
|
55093e9a28 | ||
|
|
8a69766d5d | ||
|
|
c3671944d2 | ||
|
|
5788f7d7e0 | ||
|
|
7c7c3623d4 | ||
|
|
33bf7eca90 | ||
|
|
899d021c63 | ||
|
|
9dac8304ec | ||
|
|
dda5bf31b2 | ||
|
|
3d8dac206e | ||
|
|
26cd40b47c | ||
|
|
4f79d58442 | ||
|
|
6155919514 | ||
|
|
fdc577d33e | ||
|
|
c092b0c9b2 | ||
|
|
5b15aa0de8 | ||
|
|
2e93940a67 | ||
|
|
05f1e95f14 | ||
|
|
db23064929 | ||
|
|
bccbff8b33 | ||
|
|
05e3db9e42 | ||
|
|
a47153b917 | ||
|
|
df01235325 | ||
|
|
7d20447526 | ||
|
|
0dfe551b66 | ||
|
|
94171ec548 | ||
|
|
1fbe359448 | ||
|
|
84eec0c0bf | ||
|
|
ac83abaa97 | ||
|
|
ef03651ace | ||
|
|
e39ae6847d | ||
|
|
970b56b126 | ||
|
|
5ea87fe9b7 | ||
|
|
a97abe4557 |
No files matched your search
+2
-4
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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.0rc1' # add version here
|
rev: v0.17.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: poetry-check
|
- id: ruff
|
||||||
- id: poetry-export
|
args: [ --fix ]
|
||||||
args: ["--without-hashes", "-f", "requirements.txt", "-o", "requirements.txt"]
|
|
||||||
- repo: https://github.com/pycqa/flake8
|
|
||||||
rev: "5.0.4"
|
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
additional_dependencies: [
|
|
||||||
'flake8-annotations==2.9.1',
|
|
||||||
]
|
|
||||||
@@ -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
|
disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring
|
||||||
good-names=pk
|
good-names=pk
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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/;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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']
|
||||||
Whitespace-only changes.
+5
-6
@@ -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(
|
||||||
|
|||||||
Whitespace-only changes.
+28
-20
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
Generated
+5700
-18499
File diff suppressed because it is too large.
Load diff
+29
-45
@@ -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
@@ -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
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -1,19 +1,51 @@
|
|||||||
<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>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@@ -21,9 +53,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 +71,67 @@ 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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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 +146,140 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
// The reader listens on window, so a key pressed while a control has focus would
|
||||||
|
// otherwise page as well as activate that control.
|
||||||
|
if (e.target && e.target.closest && e.target.closest('input, select, textarea, button')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.key_locked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.key_locked = true
|
||||||
|
this.key_timeout = setTimeout(() => { this.key_locked = false }, KEY_THROTTLE_MS)
|
||||||
|
|
||||||
|
// Left and Right change pages. Up and Down are left to the browser's default
|
||||||
|
// scrolling: the page is taller than the viewport, and intercepting them here would
|
||||||
|
// double up with the native scroll. In continuous mode the observer records whichever
|
||||||
|
// page is topmost, so scrolling inside a page does not change the saved page.
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
this.nextPage('auto')
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
this.prevPage('auto')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Continuous mode: reveal is not used for visibility here, so the page in view is
|
||||||
|
// worked out from what is actually intersecting the viewport.
|
||||||
|
start_scroll () {
|
||||||
|
this.visible.clear()
|
||||||
|
this.inview.clear()
|
||||||
|
|
||||||
|
this.page_observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
this.visible.set(entry.target, entry.boundingClientRect.top)
|
||||||
|
this.inview.add(Number(entry.target.dataset.page))
|
||||||
|
} else {
|
||||||
|
this.visible.delete(entry.target)
|
||||||
|
this.inview.delete(Number(entry.target.dataset.page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.visible.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = [...this.visible.entries()].sort((a, b) => a[1] - b[1])[0][0]
|
||||||
|
const page = Number(top.dataset.page)
|
||||||
|
if (page && page !== this.current_page + 1) {
|
||||||
|
this.current_page = page - 1
|
||||||
|
this.queue_save(page - 1)
|
||||||
|
}
|
||||||
|
}, { threshold: 0 })
|
||||||
|
|
||||||
|
this.growth_observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
this.extend_window()
|
||||||
|
}
|
||||||
|
}, { rootMargin: '1200px 0px' })
|
||||||
|
|
||||||
|
this.observe_pages()
|
||||||
|
if (this.$refs.sentinel) {
|
||||||
|
this.growth_observer.observe(this.$refs.sentinel)
|
||||||
|
}
|
||||||
|
this.scroll_to_page(this.current_page + 1)
|
||||||
|
},
|
||||||
|
observe_pages () {
|
||||||
|
this.$refs.slides.querySelectorAll('section[data-page]').forEach((section) => {
|
||||||
|
this.page_observer.observe(section)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
extend_window () {
|
||||||
|
if (this.rendered >= this.pages) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.rendered = Math.min(this.pages, this.rendered + WINDOW_SIZE)
|
||||||
|
this.$nextTick(() => this.observe_pages())
|
||||||
|
},
|
||||||
|
// One write per burst of scrolling rather than one per intersection.
|
||||||
|
queue_save (page) {
|
||||||
|
clearTimeout(this.save_timeout)
|
||||||
|
this.save_timeout = setTimeout(() => {
|
||||||
|
api.put(this.set_read_url, {page: page})
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
start_paged () {
|
||||||
this.deck = Reveal(this.$refs.comic_box)
|
this.deck = Reveal(this.$refs.comic_box)
|
||||||
this.deck.initialize({
|
this.deck.initialize({
|
||||||
controls: false,
|
controls: false,
|
||||||
@@ -130,29 +295,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 +414,34 @@ export default {
|
|||||||
section {
|
section {
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* reveal.css is deliberately not loaded, so visibility is the `hidden` attribute and
|
||||||
|
reveal's own transition classes do nothing. A transition cannot run across
|
||||||
|
display:none -> block, so paged mode uses an animation on the slide that becomes
|
||||||
|
present; continuous mode uses a transition driven by the IntersectionObserver. */
|
||||||
|
.reveal section.present img {
|
||||||
|
animation: page-fade 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continuous img {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continuous img.shown {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal section.present img,
|
||||||
|
.continuous img {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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'})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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){
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
|
||||||
|
const router = vi.hoisted(() => ({ push: vi.fn() }))
|
||||||
|
const deck = vi.hoisted(() => ({
|
||||||
|
initialize: vi.fn(() => Promise.resolve()),
|
||||||
|
slide: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
isFirstSlide: vi.fn(() => false),
|
||||||
|
isLastSlide: vi.fn(() => false),
|
||||||
|
}))
|
||||||
|
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
|
||||||
|
const reveal = vi.hoisted(() => vi.fn(() => deck))
|
||||||
|
const store = vi.hoisted(() => ({
|
||||||
|
state: { reader_mode: 'paged' },
|
||||||
|
commit: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const observers = []
|
||||||
|
|
||||||
|
// jsdom has no IntersectionObserver, so the tests drive the callbacks by hand.
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
constructor(callback, options) {
|
||||||
|
this.callback = callback
|
||||||
|
this.options = options
|
||||||
|
this.targets = []
|
||||||
|
this.disconnected = false
|
||||||
|
observers.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(target) {
|
||||||
|
if (!this.targets.includes(target)) this.targets.push(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve(target) { this.targets = this.targets.filter((item) => item !== target) }
|
||||||
|
disconnect() { this.disconnected = true; this.targets = [] }
|
||||||
|
fire(entries) { this.callback(entries, this) }
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({ default: api }))
|
||||||
|
vi.mock('reveal.js', () => ({ default: reveal }))
|
||||||
|
vi.mock('hammerjs', () => ({ default: vi.fn(function () { return hammertime }) }))
|
||||||
|
|
||||||
|
import TheComicReader from '@/components/TheComicReader.vue'
|
||||||
|
|
||||||
|
const COMIC = {
|
||||||
|
title: 'Alpha',
|
||||||
|
last_read_page: 2,
|
||||||
|
pages: 5,
|
||||||
|
prev_comic: { route: 'browse', selector: 'p1' },
|
||||||
|
next_comic: { route: 'browse', selector: 'n1' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const mounted = []
|
||||||
|
|
||||||
|
function mountReader() {
|
||||||
|
const wrapper = mount(TheComicReader, {
|
||||||
|
props: { selector: 'a1' },
|
||||||
|
global: {
|
||||||
|
mocks: { $router: router, $store: store },
|
||||||
|
stubs: { 'comic-paginate': true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mounted.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn()
|
||||||
|
api.get.mockReset()
|
||||||
|
api.put.mockReset()
|
||||||
|
api.get.mockResolvedValue({ data: { ...COMIC } })
|
||||||
|
api.put.mockResolvedValue({ data: {} })
|
||||||
|
router.push.mockReset()
|
||||||
|
store.state = reactive({ reader_mode: 'paged' })
|
||||||
|
store.commit.mockReset()
|
||||||
|
store.commit.mockImplementation((name, value) => {
|
||||||
|
if (name === 'setReaderMode' && (value === 'paged' || value === 'scroll')) {
|
||||||
|
store.state.reader_mode = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observers.length = 0
|
||||||
|
globalThis.IntersectionObserver = FakeIntersectionObserver
|
||||||
|
reveal.mockClear()
|
||||||
|
for (const fn of Object.values(deck)) {
|
||||||
|
if (typeof fn === 'function') fn.mockClear?.()
|
||||||
|
}
|
||||||
|
for (const fn of Object.values(hammertime)) fn.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mounted.forEach((wrapper) => {
|
||||||
|
if (!wrapper.vm?.isUnmounted) wrapper.unmount()
|
||||||
|
})
|
||||||
|
mounted.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TheComicReader', () => {
|
||||||
|
it('loads the book, starts the deck and records the page it resumed on', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/read/a1/')
|
||||||
|
expect(deck.initialize).toHaveBeenCalled()
|
||||||
|
expect(wrapper.vm.current_page).toBe(2)
|
||||||
|
expect(deck.slide).toHaveBeenCalledWith(2)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the page indicator in step with the deck', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
wrapper.vm.setPage(4)
|
||||||
|
expect(deck.slide).toHaveBeenCalledWith(3)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.vm.paginate_page).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('acts on the first arrow key and ignores held keys until the throttle expires', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||||
|
expect(wrapper.vm.current_page).toBe(3)
|
||||||
|
expect(deck.slide).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||||
|
expect(wrapper.vm.current_page).toBe(4)
|
||||||
|
expect(deck.slide).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves to the next book when the last slide is reached', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
deck.isLastSlide.mockReturnValueOnce(true)
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
|
||||||
|
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'n1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves to the previous book when the first slide is reached', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
deck.isFirstSlide.mockReturnValueOnce(true)
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowLeft' })
|
||||||
|
|
||||||
|
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'p1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records the page again whenever the visible slide changes', async () => {
|
||||||
|
mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const handler = deck.on.mock.calls[0][1]
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
handler({ indexh: 4 })
|
||||||
|
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes every listener and the deck when it is unmounted', async () => {
|
||||||
|
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||||
|
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||||
|
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(addSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
|
||||||
|
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
|
||||||
|
expect(deck.destroy).toHaveBeenCalled()
|
||||||
|
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
|
||||||
|
expect(hammertime.off).toHaveBeenCalledWith('swiperight')
|
||||||
|
expect(hammertime.off).toHaveBeenCalledWith('tap')
|
||||||
|
|
||||||
|
addSpy.mockRestore()
|
||||||
|
removeSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves arrow keys with a modifier to the browser', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
deck.slide.mockClear()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowLeft', altKey: true })
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(wrapper.vm.current_page).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores arrow keys pressed while a form control has focus', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
deck.slide.mockClear()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('input') })
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('button') })
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves Up and Down to the browser so they scroll within the page', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
deck.slide.mockClear()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowDown' })
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowUp' })
|
||||||
|
|
||||||
|
// Nothing is intercepted, so the native scroll is not doubled up and the recorded
|
||||||
|
// page stays where the reader actually is.
|
||||||
|
expect(wrapper.vm.current_page).toBe(2)
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
expect(api.put).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the top of the page when changing pages in paged mode', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
const scrollIntoView = HTMLElement.prototype.scrollIntoView
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not move the deck when a key arrives before reveal has initialized', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
deck.slide.mockClear()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
expect(deck.initialize).toHaveBeenCalled()
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(wrapper.vm.current_page).toBe(3)
|
||||||
|
expect(deck.slide).toHaveBeenCalledWith(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TheComicReader in continuous mode', () => {
|
||||||
|
function mountContinuous(pageCount) {
|
||||||
|
store.state.reader_mode = 'scroll'
|
||||||
|
api.get.mockResolvedValue({ data: { ...COMIC, pages: pageCount } })
|
||||||
|
return mountReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
function growthObserver() {
|
||||||
|
return observers.find((observer) => observer.options.rootMargin)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageObserver() {
|
||||||
|
return observers.find((observer) => !observer.options.rootMargin)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders only the first window of pages and does not start a deck', async () => {
|
||||||
|
const wrapper = mountContinuous(45)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(20)
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(20)
|
||||||
|
expect(reveal).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders far enough to include the page it resumed on', async () => {
|
||||||
|
store.state.reader_mode = 'scroll'
|
||||||
|
api.get.mockResolvedValue({ data: { ...COMIC, last_read_page: 30, pages: 45 } })
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(31)
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(31)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('grows the window when the reader reaches the end of what is rendered', async () => {
|
||||||
|
const wrapper = mountContinuous(45)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(40)
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(40)
|
||||||
|
expect(pageObserver().targets).toHaveLength(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not grow past the last page', async () => {
|
||||||
|
const wrapper = mountContinuous(25)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(25)
|
||||||
|
expect(wrapper.find('.sentinel').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records the page at the top of the viewport', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
pageObserver().fire([
|
||||||
|
{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 10 } },
|
||||||
|
{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 400 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(wrapper.vm.current_page).toBe(4)
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the recorded page while scrolling inside a page taller than the viewport', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
const observer = pageObserver()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 0 } }])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||||
|
|
||||||
|
// Scrolling down inside that page leaves it topmost, so nothing is written.
|
||||||
|
api.put.mockClear()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: -300 } }])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(wrapper.vm.current_page).toBe(4)
|
||||||
|
expect(api.put).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// The recorded page only changes when the next page becomes the topmost one.
|
||||||
|
observer.fire([
|
||||||
|
{ isIntersecting: false, target: sections[4].element },
|
||||||
|
{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 0 } },
|
||||||
|
])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(wrapper.vm.current_page).toBe(5)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes once for a burst of scrolling rather than once per intersection', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
const observer = pageObserver()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 400 } }])
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[6].element, boundingClientRect: { top: 0 } }])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
|
||||||
|
expect(api.put).toHaveBeenCalledTimes(1)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 6 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the page in view as shown so it fades in', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
const observer = pageObserver()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(sections[2].find('img').classes()).toContain('shown')
|
||||||
|
expect(sections[0].find('img').classes()).not.toContain('shown')
|
||||||
|
|
||||||
|
observer.fire([{ isIntersecting: false, target: sections[2].element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
expect(sections[2].find('img').classes()).not.toContain('shown')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scrolls to a page chosen from the pager, rendering it first if it is outside the window', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const target = wrapper.findAll('section[data-page]')[19].element
|
||||||
|
const spy = vi.spyOn(target, 'scrollIntoView')
|
||||||
|
|
||||||
|
wrapper.vm.setPage(20)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(20)
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disconnects both observers when it is unmounted', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
expect(observers.every((observer) => observer.disconnected)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits the mode change and rebuilds the deck when switching back to one page at a time', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.vm.setMode('paged')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'paged')
|
||||||
|
expect(observers.every((observer) => observer.disconnected)).toBe(true)
|
||||||
|
expect(reveal).toHaveBeenCalled()
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(30)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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
-2196
File diff suppressed because it is too large.
Load diff
+65
-47
@@ -1,55 +1,73 @@
|
|||||||
[tool.black]
|
[project]
|
||||||
line_length = 119
|
|
||||||
|
|
||||||
[tool.poetry]
|
|
||||||
name = "cbwebreader"
|
name = "cbwebreader"
|
||||||
version = "1.1.1"
|
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.1"
|
"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.1.1"
|
"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",
|
||||||
|
]
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
pep8maxlinelength = 119
|
|
||||||
python_files = tests.py test_*.py *_tests.py
|
|
||||||
@@ -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==1.7.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.14 ; python_version >= "3.10" and python_version < "4"
|
|
||||||
charset-normalizer==2.1.1 ; python_version >= "3.10" and python_version < "4"
|
|
||||||
colorama==0.4.5 ; 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.1 ; 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.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
django==4.1.1 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
djangorestframework-simplejwt==5.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
djangorestframework==3.13.1 ; 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.3 ; 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.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
psycopg2==2.9.3 ; 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.4.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.2.1 ; 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.6 ; 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.3.0 ; 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.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
toml==0.10.2 ; python_version >= "3.10" and python_version < "4.0"
|
|
||||||
tzdata==2022.2 ; 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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
Whitespace-only changes.
Reference in new issue
Block a user