├── .dockerignore ├── .editorconfig ├── .env.example ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ ├── feature-request.yml │ └── question.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md └── workflows │ ├── deploy.yml │ ├── release.yml │ ├── scans.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── MangAdventure ├── __init__.py ├── __main__.py ├── bad_bots.py ├── cache.py ├── converters.py ├── fields.py ├── filters.py ├── forms.py ├── jsonld.py ├── middleware.py ├── search.py ├── settings.py ├── sitemaps.py ├── storage.py ├── templates │ ├── account │ │ ├── account_inactive.html │ │ ├── email │ │ │ ├── email_confirmation_message.txt │ │ │ ├── email_confirmation_signup_message.txt │ │ │ ├── email_confirmation_signup_subject.txt │ │ │ ├── email_confirmation_subject.txt │ │ │ ├── password_reset_key_message.txt │ │ │ └── password_reset_key_subject.txt │ │ ├── email_confirm.html │ │ ├── login.html │ │ ├── password_reset.html │ │ ├── password_reset_done.html │ │ ├── password_reset_from_key.html │ │ ├── password_reset_from_key_done.html │ │ ├── signup.html │ │ └── verification_sent.html │ ├── contribute.json │ ├── error.html │ ├── flatpages │ │ └── default.html │ ├── footer.html │ ├── image-sitemap.xml │ ├── index.html │ ├── layout.html │ ├── manifest.webmanifest │ ├── opensearch.xml │ ├── rapidoc.html │ ├── search.html │ └── socialaccount │ │ └── authentication_error.html ├── tests │ ├── __init__.py │ ├── base.py │ ├── settings.py │ ├── test_cache.py │ ├── test_forms.py │ ├── test_middleware.py │ ├── test_sitemaps.py │ ├── test_storage.py │ ├── test_validators.py │ ├── test_views.py │ └── utils.py ├── urls.py ├── utils.py ├── validators.py ├── views.py ├── widgets.py └── wsgi.py ├── README.md ├── api ├── __init__.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── fixtures │ │ ├── authors_artists.json │ │ ├── chapters.json │ │ ├── groups.json │ │ └── series.json │ ├── test_v1.py │ └── test_v2.py ├── urls.py ├── v1 │ ├── __init__.py │ ├── apps.py │ ├── response.py │ ├── urls.py │ └── views.py └── v2 │ ├── __init__.py │ ├── apps.py │ ├── auth.py │ ├── mixins.py │ ├── negotiation.py │ ├── pagination.py │ ├── schema.py │ ├── urls.py │ └── views.py ├── config ├── __init__.py ├── admin.py ├── apps.py ├── context_processors.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── clearcache.py │ │ ├── createsuperuser.py │ │ ├── fs2import.py │ │ └── logs.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_scanlator_permissions.py │ └── __init__.py ├── templatetags │ ├── __init__.py │ ├── custom_tags.py │ └── flatpage_tags.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── fixtures │ │ ├── foolslide2 │ │ │ └── foolslide.xml │ │ ├── info_pages.json │ │ └── users.json │ ├── test_admin.py │ ├── test_fs2import.py │ ├── test_tags.py │ └── test_urls.py └── urls.py ├── docker ├── Dockerfile └── uwsgi.ini ├── docs ├── .readthedocs.yaml ├── Makefile ├── _ext │ └── mangadventure_patches.py ├── _static │ ├── css │ │ └── style.css │ └── logo.png ├── api │ ├── artists │ │ ├── all.rst │ │ ├── index.rst │ │ └── single.rst │ ├── authors │ │ ├── all.rst │ │ ├── index.rst │ │ └── single.rst │ ├── categories.rst │ ├── groups │ │ ├── all.rst │ │ ├── index.rst │ │ └── single.rst │ ├── includes │ │ ├── chapter.rst │ │ ├── group.rst │ │ ├── headers-etag.rst │ │ ├── headers-modified.rst │ │ ├── series.rst │ │ └── status.rst │ ├── index.rst │ ├── releases.rst │ └── series │ │ ├── all.rst │ │ ├── chapter.rst │ │ ├── index.rst │ │ ├── single.rst │ │ └── volume.rst ├── changelog.rst ├── compatibility.rst ├── conf.py ├── examples │ ├── apache.conf │ └── nginx.conf ├── index.rst ├── install.rst ├── modules │ ├── MangAdventure.rst │ ├── api.rst │ ├── api.v1.rst │ ├── api.v2.rst │ ├── config.management.commands.rst │ ├── config.management.rst │ ├── config.rst │ ├── config.templatetags.rst │ ├── groups.rst │ ├── groups.templatetags.rst │ ├── index.rst │ ├── reader.rst │ ├── users.rst │ └── users.templatetags.rst └── roadmap.rst ├── groups ├── __init__.py ├── admin.py ├── api.py ├── apps.py ├── feeds.py ├── migrations │ ├── 0001_squashed.py │ ├── 0002_managers.py │ ├── 0003_alter_group_id.py │ ├── 0004_constraints.py │ └── __init__.py ├── models.py ├── serializers.py ├── sitemaps.py ├── templates │ ├── all_groups.html │ └── group.html ├── templatetags │ ├── __init__.py │ └── group_tags.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── fixtures │ │ └── users.json │ ├── test_admin.py │ ├── test_feeds.py │ ├── test_models.py │ ├── test_sitemaps.py │ ├── test_tags.py │ └── test_views.py ├── urls.py └── views.py ├── manage.py ├── pyproject.toml ├── reader ├── __init__.py ├── admin.py ├── api.py ├── apps.py ├── feeds.py ├── filters.py ├── fixtures │ └── categories.xml ├── migrations │ ├── 0001_squashed.py │ ├── 0002_series_created.py │ ├── 0003_chapter_published.py │ ├── 0004_aliases.py │ ├── 0005_managers.py │ ├── 0006_file_limits.py │ ├── 0007_series_licensed.py │ ├── 0008_chapter_views.py │ ├── 0009_constraints.py │ ├── 0010_null_volumes.py │ ├── 0011_series_status.py │ └── __init__.py ├── models.py ├── receivers.py ├── serializers.py ├── sitemaps.py ├── templates │ ├── chapter.html │ ├── directory.html │ └── series.html ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── fixtures │ │ └── users.json │ ├── test_admin.py │ ├── test_feeds.py │ ├── test_models.py │ ├── test_receivers.py │ ├── test_sitemaps.py │ └── test_views.py ├── urls.py └── views.py ├── render.yaml ├── scripts ├── deploy.sh └── lint.sh ├── setup.py ├── static ├── scripts │ ├── bookmark.js │ ├── chapter.js │ ├── search.js │ └── tinymce-init.js ├── styles │ ├── _mixins.scss │ ├── chapter.scss │ ├── noscript.css │ ├── style.scss │ └── tinymce.scss └── vendor │ ├── rapidoc-min.js │ ├── tablesort.min.js │ └── umami.js └── users ├── __init__.py ├── adapters.py ├── admin.py ├── api.py ├── apps.py ├── backends.py ├── feeds.py ├── forms.py ├── migrations ├── 0001_squashed.py ├── 0002_userprofile_token.py ├── 0003_apikey.py ├── 0004_constraints.py └── __init__.py ├── models.py ├── serializers.py ├── templates ├── bookmarks.html ├── delete.html ├── edit_user.html └── profile.html ├── templatetags ├── __init__.py └── user_tags.py ├── tests ├── __init__.py ├── conftest.py ├── fixtures │ ├── series.json │ └── users.json ├── test_adapters.py ├── test_admin.py ├── test_backends.py ├── test_feeds.py ├── test_forms.py ├── test_models.py ├── test_tags.py ├── test_utils.py └── test_views.py ├── urls.py └── views.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # OS stuff 2 | .directory 3 | desktop.ini 4 | Thumbs.db 5 | .DS_Store 6 | 7 | # Python/Django stuff 8 | **/migrations/*auto* 9 | __pycache__/ 10 | .mypy_cache/ 11 | .pytest_cache/ 12 | logs/ 13 | db.sqlite3 14 | *.pyc 15 | 16 | # Environment stuff 17 | .venv/ 18 | env/ 19 | venv/ 20 | .python-version 21 | .env 22 | 23 | # Setuptools stuff 24 | build/ 25 | dist/ 26 | .eggs/ 27 | PKG-INFO 28 | *.egg-info 29 | *.egg-link 30 | *.whl 31 | 32 | # Sphinx stuff 33 | _build/ 34 | _templates/ 35 | 36 | # Coverage stuff 37 | htmlcov/ 38 | coverage.xml 39 | .coverage 40 | 41 | # Editor stuff 42 | .idea/ 43 | .settings/ 44 | .classpath 45 | .project 46 | *.pydevproject 47 | *.cache 48 | *.sublime-workspace 49 | *.sublime-project 50 | .*vimrc 51 | *.swp 52 | *~ 53 | 54 | # Server stuff 55 | .well-known/ 56 | .htaccess 57 | *.log 58 | *.ini 59 | *.pid 60 | *.sock 61 | *.gz 62 | *.br 63 | status_auth.txt 64 | !docker/uwsgi.ini 65 | 66 | # Redis stuff 67 | *.rdb 68 | *.aof 69 | 70 | # MangAdventure stuff 71 | media/ 72 | static/admin/ 73 | static/extra/ 74 | static/COMPILED/ 75 | static/debug_toolbar/ 76 | static/rest_framework/ 77 | static/styles/_variables.scss 78 | 79 | # GitHub Stuff 80 | docs/changes.md 81 | 82 | ## Docker only 83 | .git/ 84 | .github/ 85 | .gitignore 86 | .gitattributes 87 | .readthedocs.yml 88 | app.json 89 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.py] 12 | indent_size = 4 13 | max_line_length = 80 14 | quote_type = single 15 | 16 | [*.{ht,x}ml] 17 | quote_type = double 18 | 19 | [*.js] 20 | max_line_length = 80 21 | quote_type = single 22 | curly_bracket_next_line = false 23 | 24 | [*.json] 25 | curly_bracket_next_line = false 26 | 27 | [*.{s,}css] 28 | quote_type = single 29 | curly_bracket_next_line = false 30 | 31 | [*.rst] 32 | indent_size = 3 33 | 34 | [Makefile] 35 | indent_size = 4 36 | indent_style = tab 37 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | docs/* linguist-documentation 4 | 5 | static/vendor/* linguist-vendored 6 | static/admin/* linguist-vendored 7 | static/django_tinymce/* linguist-vendored 8 | 9 | static/COMPILED/* linguist-generated 10 | 11 | .env.example linguist-detectable=false linguist-language=Shell 12 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Merit 2 | 3 | 1. The project creators, lead developers, core team, constitute 4 | the managing members of the project and have final say in every decision 5 | of the project, technical or otherwise, including overruling previous decisions. 6 | There are no limitations to this decisional power. 7 | 8 | 2. Contributions are an expected result of your membership on the project. 9 | Don't expect others to do your work or help you with your work forever. 10 | 11 | 3. All members have the same opportunities to seek any challenge they want 12 | within the project. 13 | 14 | 4. Authority or position in the project will be proportional 15 | to the accrued contribution. Seniority must be earned. 16 | 17 | 5. Software is evolutive: the better implementations must supersede lesser 18 | implementations. Technical advantage is the primary evaluation metric. 19 | 20 | 6. This is a space for technical prowess; topics outside of the project 21 | will not be tolerated. 22 | 23 | 7. Non technical conflicts will be discussed in a separate space. Disruption 24 | of the project will not be allowed. 25 | 26 | 8. Individual characteristics, including but not limited to, 27 | body, sex, sexual preference, race, language, religion, nationality, 28 | or political preferences are irrelevant in the scope of the project and 29 | will not be taken into account concerning your value or that of your contribution 30 | to the project. 31 | 32 | 9. Discuss or debate the idea, not the person. 33 | 34 | 10. There is no room for ambiguity: Ambiguity will be met with questioning; 35 | further ambiguity will be met with silence. It is the responsibility 36 | of the originator to provide requested context. 37 | 38 | 11. If something is illegal outside the scope of the project, it is illegal 39 | in the scope of the project. This Code of Merit does not take precedence over 40 | governing law. 41 | 42 | 12. This Code of Merit governs the technical procedures of the project not the 43 | activities outside of it. 44 | 45 | 13. Participation on the project equates to agreement of this Code of Merit. 46 | 47 | 14. No objectives beyond the stated objectives of this project are relevant 48 | to the project. Any intent to deviate the project from its original purpose 49 | of existence will constitute grounds for remedial action which may include 50 | expulsion from the project. 51 | 52 | This document is the Code of Merit (http://code-of-merit.org), version 1.0. 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json 2 | name: 🐛 Bug Report 3 | labels: ["Type: Bug"] 4 | description: Did something not work as expected? 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: >- 11 | Provide a detailed description of the 12 | issue, and why you consider it to be a bug. 13 | validations: {required: true} 14 | - type: input 15 | id: expected 16 | attributes: 17 | label: Expected Behavior 18 | description: Tell us what should happen. 19 | validations: {required: true} 20 | - type: input 21 | id: actual 22 | attributes: 23 | label: Actual Behavior 24 | description: Tell us what happens instead. 25 | validations: {required: true} 26 | - type: textarea 27 | id: suggestion 28 | attributes: 29 | label: Possible Fix 30 | description: >- 31 | Can you suggest a fix or reason for the bug? 32 | validations: {required: false} 33 | - type: textarea 34 | id: reproduce 35 | attributes: 36 | label: Steps to Reproduce 37 | description: >- 38 | Provide some screenshots, or an unambiguous set of steps to 39 | reproduce this bug. Include code to reproduce, if relevant. 40 | validations: {required: true} 41 | - type: textarea 42 | id: environment 43 | attributes: 44 | label: Your Environment 45 | description: >- 46 | Include as many relevant details about your environment as possible. 47 | You can remove the ones that don't apply to your case. 48 | value: |- 49 | * MangAdventure version: 50 | * Operating System and version: 51 | * Python version: 52 | * Web server and version: 53 | * Browser type and version: 54 | * Database type and version: 55 | * Link to your website: 56 | validations: {required: false} 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: 💬 Discord Server 5 | url: https://discord.gg/GsJyhSz 6 | about: Join our server for support, or just to chat. 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json 2 | name: ✨ Feature Request 3 | labels: ["Type: Enhancement"] 4 | description: Do you want something changed or implemented? 5 | 6 | body: 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: Description 11 | description: >- 12 | Provide a detailed description of the 13 | change or addition you are proposing. 14 | validations: {required: true} 15 | - type: textarea 16 | id: context 17 | attributes: 18 | label: Context 19 | description: >- 20 | Why is this change or addition important to you? 21 | How would you use it, and how can it benefit other users? 22 | validations: {required: true} 23 | - type: textarea 24 | id: suggestion 25 | attributes: 26 | label: Possible Implementation 27 | description: >- 28 | Can you suggest an idea for implementing the feature? 29 | validations: {required: false} 30 | - type: textarea 31 | id: environment 32 | attributes: 33 | label: Your Environment 34 | description: >- 35 | Include as many relevant details about your environment as possible. 36 | You can remove the ones that don't apply to your case. 37 | value: |- 38 | * MangAdventure version: 39 | * Operating System and version: 40 | * Python version: 41 | * Web server and version: 42 | * Browser type and version: 43 | validations: {required: false} 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json 2 | name: ❓ Question 3 | labels: ["Type: Question"] 4 | description: Do you have a question regarding this project? 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: |- 9 | Please read the [documentation][] before submitting your question. 10 | 11 | [documentation]: https://mangadventure.readthedocs.io/en/latest/ 12 | - type: textarea 13 | id: question 14 | attributes: 15 | label: Question 16 | description: Ask your question here. 17 | validations: {required: true} 18 | - type: input 19 | id: version 20 | attributes: 21 | label: MangAdventure Version 22 | description: Which version does your question concern? 23 | validations: {required: false} 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## Notes 5 | 6 | 7 | ## Checklist 8 | 9 | 10 | * [ ] I have read the contributor guidelines. 11 | * [ ] I have documented and/or commented my code. 12 | * [ ] I have updated the docs as necessary. 13 | * [ ] I have updated the tests as necessary. 14 | * [ ] I have verified that all tests pass locally. 15 | 16 | ## Your Environment 17 | 18 | 19 | * Operating System and version: 20 | * Python version: 21 | * Web server and version: 22 | * Browser type and version: 23 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | We take security very seriously and have automated 4 | [dependency alerts][] and [code scanning][] to make sure the project is secure. 5 | If you still somehow find a vulnerability, here's how you can report it. 6 | 7 | [dependency alerts]: https://github.com/mangadventure/MangAdventure/security/dependabot 8 | [code scanning]: https://github.com/mangadventure/MangAdventure/security/code-scanning 9 | 10 | ## Supported Versions 11 | 12 | During the beta phase, we will only patch security vulnerabilities in the [latest][latest] beta release. 13 | 14 | [latest]: https://github.com/mangadventure/MangAdventure/releases/latest 15 | 16 | ## Reporting a Vulnerability 17 | 18 | * Do not create issues to report security vulnerabilities. 19 | * Instead, please e-mail the security maintainer at [chronobserver@disroot.org](mailto:chronobserver@disroot.org). 20 | * You may encrypt the e-mail if you want (PGP key: `0x8A2DEA1DBAEBCA9E`). 21 | * Avoid including any confidential information in the e-mail. 22 | * Provide your GitHub username (if available), so that we can invite you to collaborate on a [security advisory][advisories]. 23 | * Alternatively, you can report the vulnerability [here][report]. 24 | 25 | [advisories]: https://help.github.com/en/articles/about-maintainer-security-advisories 26 | [report]: https://github.com/mangadventure/MangAdventure/security/advisories/new 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | run-name: ${{github.event.workflow_run.head_commit.message}} 4 | 5 | on: 6 | workflow_run: 7 | workflows: [Tests] 8 | types: [completed] 9 | branches: [master] 10 | 11 | jobs: 12 | render: 13 | name: "Render" 14 | runs-on: ubuntu-latest 15 | environment: 16 | name: render 17 | url: https://mangadventure.onrender.com/ 18 | if: github.event.workflow_run.conclusion == 'success' 19 | steps: 20 | - name: "Checkout repository" 21 | uses: actions/checkout@v4 22 | - name: "Check for file changes" 23 | id: changed-files 24 | uses: tj-actions/changed-files@v40 25 | with: 26 | files_ignore: |- 27 | LICENSE 28 | README.md 29 | MANIFEST.in 30 | .editorconfig 31 | .gitattributes 32 | .dockerignore 33 | .gitignore 34 | .github/** 35 | docker/** 36 | docs/** 37 | */tests/** 38 | - name: "Deploy on Render" 39 | if: steps.changed-files.outputs.any_changed == 'true' 40 | run: curl -Ssf "$RENDER_DEPLOY_HOOK" 41 | env: 42 | RENDER_DEPLOY_HOOK: ${{secrets.RENDER_DEPLOY_HOOK}} 43 | -------------------------------------------------------------------------------- /.github/workflows/scans.yml: -------------------------------------------------------------------------------- 1 | name: Scans 2 | 3 | run-name: >- 4 | ${{github.event_name == 'pull_request' && 5 | github.event.head_commit.message || 'Code scanning'}} 6 | 7 | on: 8 | workflow_dispatch: 9 | pull_request: 10 | branches: [master] 11 | paths: 12 | - "**/*.py" 13 | - "**/*.js" 14 | - "pyproject.toml" 15 | 16 | jobs: 17 | codeql: 18 | name: "CodeQL" 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: "Checkout repository" 22 | uses: actions/checkout@v4 23 | - name: "Initialize CodeQL" 24 | uses: github/codeql-action/init@v2 25 | with: 26 | languages: javascript,python 27 | - name: "Perform CodeQL analysis" 28 | uses: github/codeql-action/analyze@v2 29 | 30 | dependency-review: 31 | name: "Dependencies" 32 | runs-on: ubuntu-latest 33 | if: github.event_name == 'pull_request' 34 | steps: 35 | - name: "Checkout repository" 36 | uses: actions/checkout@v4 37 | - name: "Review dependencies" 38 | uses: actions/dependency-review-action@v3 39 | with: 40 | fail-on-severity: high 41 | comment-summary-in-pr: on-failure 42 | deny-licenses: >- 43 | AGPL-3.0-only, 44 | AGPL-3.0-or-later, 45 | GPL-2.0-only, 46 | GPL-2.0-or-later, 47 | GPL-3.0-only, 48 | GPL-3.0-or-later 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS stuff 2 | .directory 3 | desktop.ini 4 | Thumbs.db 5 | .DS_Store 6 | 7 | # Python/Django stuff 8 | **/migrations/*auto* 9 | __pycache__/ 10 | .mypy_cache/ 11 | .pytest_cache/ 12 | logs/ 13 | db.sqlite3 14 | *.pyc 15 | 16 | # Environment stuff 17 | .venv/ 18 | env/ 19 | venv/ 20 | .python-version 21 | .env 22 | 23 | # Setuptools stuff 24 | build/ 25 | dist/ 26 | .eggs/ 27 | PKG-INFO 28 | *.egg-info 29 | *.egg-link 30 | *.whl 31 | 32 | # Sphinx stuff 33 | _build/ 34 | _templates/ 35 | 36 | # Coverage stuff 37 | htmlcov/ 38 | coverage.xml 39 | .coverage 40 | 41 | # Editor stuff 42 | .idea/ 43 | .settings/ 44 | .classpath 45 | .project 46 | *.pydevproject 47 | *.cache 48 | *.sublime-workspace 49 | *.sublime-project 50 | .*vimrc 51 | *.swp 52 | *~ 53 | 54 | # Server stuff 55 | .well-known/ 56 | .htaccess 57 | *.log 58 | *.ini 59 | *.pid 60 | *.sock 61 | *.gz 62 | *.br 63 | status_auth.txt 64 | !docker/uwsgi.ini 65 | 66 | # Redis stuff 67 | *.rdb 68 | *.aof 69 | 70 | # MangAdventure stuff 71 | media/ 72 | static/admin/ 73 | static/COMPILED/ 74 | static/debug_toolbar/ 75 | static/rest_framework/ 76 | static/styles/_variables.scss 77 | static/styles/extra.scss 78 | 79 | # GitHub Stuff 80 | docs/changes.md 81 | *.sarif 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2023 MangAdventure 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include static/scripts * 2 | recursive-include static/styles * 3 | recursive-include static/vendor * 4 | recursive-include */templates * 5 | recursive-exclude */tests * 6 | -------------------------------------------------------------------------------- /MangAdventure/__init__.py: -------------------------------------------------------------------------------- 1 | """A simple manga hosting CMS written in Django.""" 2 | 3 | __version__ = '0.9.6' 4 | -------------------------------------------------------------------------------- /MangAdventure/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import environ 4 | from sys import argv 5 | 6 | 7 | def run(): 8 | environ['DJANGO_SETTINGS_MODULE'] = 'MangAdventure.settings' 9 | from django.core.management import execute_from_command_line 10 | execute_from_command_line(argv) 11 | 12 | 13 | if __name__ == '__main__': run() # noqa: E701 14 | -------------------------------------------------------------------------------- /MangAdventure/bad_bots.py: -------------------------------------------------------------------------------- 1 | BOTS = [ 2 | '2ip bot', 3 | 'Advanced Email Extractor', 4 | 'AhrefsBot', 5 | 'Atomic_Email_Hunter', 6 | 'atSpider', 7 | 'autoemailspider', 8 | 'BlackWidow', 9 | 'Boston Project', 10 | 'bwh3_user_agent', 11 | 'Bytespider', 12 | 'China Local Browse', 13 | 'ContactBot', 14 | 'ContentSmartz', 15 | 'DataCha0s', 16 | 'Dataprovider.com', 17 | 'del.icio.us-thumbnails', 18 | 'Demo Bot', 19 | 'DigExt; DTS Agent', 20 | 'DotBot', 21 | 'Educate Search', 22 | 'efp@gmx.net', 23 | 'EmailCollector', 24 | 'EmailSiphon' 25 | 'emailspider', 26 | 'EmailWolf', 27 | 'ExtractorPro', 28 | 'Franklin Locator', 29 | 'Full Web Bot', 30 | 'GPTBot', 31 | 'Guestbook Auto Submitter', 32 | 'Industry Program', 33 | 'Indy Library', 34 | 'Iplexx Spider', 35 | 'ISC Systems iRc Search', 36 | 'IUPUI Research Bot', 37 | 'jaja-jak-globusy.com', 38 | 'JOC Web Spider', 39 | 'LetsCrawl.com', 40 | 'LightspeedSystemsCrawler', 41 | 'Lincoln State Web Browser', 42 | 'LMQueueBot', 43 | 'LWP::Simple', 44 | 'Mac Finder', 45 | 'Mail.RU_Bot', 46 | 'MFC Foundation Class Library', 47 | 'Microsoft URL Control', 48 | 'Missauga Locate', 49 | 'Missigua Locator', 50 | 'Missouri College Browse', 51 | 'Mizzu Labs', 52 | 'Mo College', 53 | 'MVAClient', 54 | 'NameOfAgent (CMS Spider)', 55 | 'NASA Search', 56 | 'netcraft.com', 57 | 'NEWT ActiveX', 58 | 'Nsauditor', 59 | 'Port Huron Labs', 60 | 'Production Bot', 61 | 'Program Shareware', 62 | 'psycheclone', 63 | 'rom1504/img2dataset', 64 | 'scan4mail', 65 | 'searchbot', 66 | 'SemrushBot', 67 | 'ShablastBot', 68 | 'SiteSucker', 69 | 'snap.com beta crawler', 70 | 'Snapbot', 71 | 'Sogou', 72 | 'sogouspider', 73 | 'sohu agent', 74 | 'Surfbot', 75 | 'TrackBack', 76 | 'Turnitin', 77 | 'Under the Rainbow', 78 | 'VadixBot', 79 | 'WebCapture', 80 | 'WebEMailExtrac', 81 | 'WebFetch', 82 | 'Website eXtractor', 83 | 'WebVulnCrawl', 84 | 'Wells Search', 85 | 'Wep Search', 86 | 'Wysigot', 87 | 'YouBot', 88 | ] 89 | -------------------------------------------------------------------------------- /MangAdventure/converters.py: -------------------------------------------------------------------------------- 1 | """ 2 | `Path converters`_ used in URL patterns. 3 | 4 | .. _`Path converters`: 5 | https://docs.djangoproject.com/en/4.1/topics/http/urls/#path-converters 6 | """ 7 | 8 | 9 | class FloatConverter: 10 | """Path converter that matches a float number.""" 11 | #: The pattern to match. 12 | regex = r'\d+(\.\d+)?' 13 | 14 | def to_python(self, value: str) -> float: 15 | """ 16 | Convert the matched string into the type 17 | that should be passed to the view function. 18 | 19 | :param value: The matched string. 20 | 21 | :return: The match as a ``float``. 22 | """ 23 | return float(value) 24 | 25 | def to_url(self, value: str) -> str: 26 | """ 27 | Convert the Python type into a string to be used in the URL. 28 | 29 | :param value: The matched string. 30 | 31 | :return: The match with trailing zeroes removed. 32 | """ 33 | return f'{float(value):g}' 34 | 35 | 36 | __all__ = ['FloatConverter'] 37 | -------------------------------------------------------------------------------- /MangAdventure/fields.py: -------------------------------------------------------------------------------- 1 | """Custom database models & model fields.""" 2 | 3 | from django.db.models import CharField, URLField 4 | 5 | from .validators import ( 6 | DiscordNameValidator, DiscordServerValidator, 7 | RedditNameValidator, TwitterNameValidator 8 | ) 9 | 10 | 11 | class TwitterField(CharField): 12 | """A :class:`~django.db.models.CharField` for Twitter usernames.""" 13 | default_validators = (TwitterNameValidator(),) 14 | 15 | def __init__(self, *args, **kwargs): 16 | kwargs['max_length'] = 15 17 | super().__init__(*args, **kwargs) 18 | 19 | 20 | class DiscordNameField(CharField): 21 | """A :class:`~django.db.models.CharField` for Discord usernames.""" 22 | default_validators = (DiscordNameValidator(),) 23 | 24 | def __init__(self, *args, **kwargs): 25 | kwargs['max_length'] = 37 26 | super().__init__(*args, **kwargs) 27 | 28 | 29 | class RedditField(CharField): 30 | """A :class:`~django.db.models.CharField` for Reddit names.""" 31 | default_validators = (RedditNameValidator(),) 32 | 33 | def __init__(self, *args, **kwargs): 34 | kwargs.setdefault('max_length', 21) 35 | super().__init__(*args, **kwargs) 36 | 37 | 38 | class DiscordURLField(URLField): 39 | """A :class:`~django.db.models.CharField` for Discord server URLs.""" 40 | default_validators = (DiscordServerValidator(),) 41 | 42 | 43 | __all__ = ['TwitterField', 'DiscordNameField', 'DiscordURLField', 'RedditField'] 44 | -------------------------------------------------------------------------------- /MangAdventure/forms.py: -------------------------------------------------------------------------------- 1 | """Custom form fields.""" 2 | 3 | from django.forms import CharField, URLField 4 | 5 | from MangAdventure import validators 6 | 7 | 8 | class TwitterField(CharField): 9 | """A :class:`~django.forms.CharField` for Twitter usernames.""" 10 | default_validators = [validators.TwitterNameValidator()] 11 | 12 | 13 | class DiscordURLField(URLField): 14 | """A :class:`~django.forms.URLField` for Discord server URLs.""" 15 | default_validators = [validators.DiscordServerValidator()] 16 | 17 | 18 | __all__ = ['TwitterField', 'DiscordURLField'] 19 | -------------------------------------------------------------------------------- /MangAdventure/jsonld.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions used to generate JSON-LD_ objects. 3 | 4 | .. _JSON-LD: https://json-ld.org/ 5 | """ 6 | 7 | from typing import Any, Dict, List, Tuple 8 | 9 | JSON = Dict[str, Any] 10 | 11 | 12 | def schema(at_type: str, items: JSON) -> JSON: 13 | """ 14 | Generate an arbitrary JSON-LD object. 15 | 16 | The object's ``@context`` links to https://schema.org. 17 | 18 | :param at_type: The ``@type`` of the object. 19 | :param items: The key-value pairs of the object. 20 | 21 | :return: A JSON-LD dictionary. 22 | """ 23 | return { 24 | '@context': 'https://schema.org', 25 | '@type': at_type, **items 26 | } 27 | 28 | 29 | def breadcrumbs(items: List[Tuple[str, str]]) -> JSON: 30 | """ 31 | Generate a :schema:`BreadcrumbList` JSON-LD object. 32 | 33 | :param items: A list of :schema:`ListItem` tuples. The first 34 | element of each tuple is the :schema:`name` 35 | and the second is the :schema:`item`. 36 | 37 | :return: A JSON-LD dictionary. 38 | 39 | .. seealso:: 40 | 41 | https://developers.google.com/search/docs/data-types/breadcrumb 42 | """ 43 | return schema('BreadcrumbList', { 44 | 'itemListElement': [{ 45 | '@type': 'ListItem', 46 | 'position': pos, 47 | 'name': name, 48 | 'item': item 49 | } for pos, (name, item) in enumerate(items, 1)] 50 | }) 51 | 52 | 53 | def carousel(items: List[str]) -> JSON: 54 | """ 55 | Generate an :schema:`ItemList` JSON-LD object. 56 | 57 | :param items: A list of :schema:`ListItem` :schema:`urls ` 58 | 59 | :return: A JSON-LD dictionary. 60 | 61 | .. seealso:: 62 | 63 | https://developers.google.com/search/docs/data-types/carousel 64 | """ 65 | return schema('ItemList', { 66 | 'itemListElement': [{ 67 | '@type': 'ListItem', 68 | 'position': pos, 69 | 'url': url 70 | } for pos, url in enumerate(items, 1)] 71 | }) 72 | 73 | 74 | __all__ = ['schema', 'breadcrumbs', 'carousel'] 75 | -------------------------------------------------------------------------------- /MangAdventure/middleware.py: -------------------------------------------------------------------------------- 1 | """Custom middleware.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from django.http import HttpResponse 8 | from django.middleware.common import CommonMiddleware 9 | 10 | if TYPE_CHECKING: # pragma: no cover 11 | from django.http import HttpRequest 12 | 13 | 14 | class HttpResponseTooEarly(HttpResponse): 15 | status_code = 425 16 | 17 | 18 | class BaseMiddleware(CommonMiddleware): 19 | """``CommonMiddleware`` with custom patches.""" 20 | 21 | def __call__(self, request: HttpRequest) -> HttpResponse: 22 | """ 23 | Patched to allow :const:`blocked user agents 24 | ` 25 | to view ``/robots.txt``. 26 | 27 | It also sends a :status:`425` response if 28 | the :header:`Early-Data` header has been set. 29 | 30 | :param request: The original request. 31 | 32 | :return: The response to the request. 33 | """ 34 | if request.path == '/robots.txt': 35 | return self.get_response(request) # type: ignore 36 | if request.method != 'GET' and \ 37 | request.META.get('HTTP_EARLY_DATA') == '1': 38 | return HttpResponseTooEarly() 39 | return super().__call__(request) # type: ignore 40 | 41 | 42 | __all__ = ['BaseMiddleware'] 43 | -------------------------------------------------------------------------------- /MangAdventure/sitemaps.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous sitemaps.""" 2 | 3 | from typing import Tuple 4 | 5 | from django.contrib.sitemaps import Sitemap 6 | from django.urls import reverse 7 | 8 | 9 | class MiscSitemap(Sitemap): 10 | """Sitemap for miscellaneous pages.""" 11 | 12 | def items(self) -> Tuple[str, ...]: 13 | """ 14 | Get a tuple of the sitemap's items. 15 | 16 | :return: A tuple of page names. 17 | """ 18 | return ('index', 'search', 'reader:directory', 'info', 'privacy') 19 | 20 | def location(self, item: str) -> str: 21 | """ 22 | Get the location of the item. 23 | 24 | :param item: A page name. 25 | 26 | :return: The URL of the page. 27 | """ 28 | return reverse(item) 29 | 30 | def priority(self, item: str) -> float: 31 | """ 32 | Get the priority of the item. 33 | 34 | :param item: A page name. 35 | 36 | :return: The priority of the page. 37 | """ 38 | return 0.8 if self._is_reader(item) else 0.5 39 | 40 | def changefreq(self, item: str) -> str: 41 | """ 42 | Get the change frequency of the item. 43 | 44 | :param item: A page name. 45 | 46 | :return: The change frequency of the page. 47 | """ 48 | return 'daily' if self._is_reader(item) else 'never' 49 | 50 | @staticmethod 51 | def _is_reader(item: str) -> bool: 52 | return item == 'index' or item == 'reader:directory' 53 | 54 | 55 | __all__ = ['MiscSitemap'] 56 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/account_inactive.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | Inactive account · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Inactive account

13 |
This account is inactive.
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_message.txt: -------------------------------------------------------------------------------- 1 | {% load account %} 2 | {% user_display user as user_display %} 3 | {% autoescape off %} 4 | {% with site_name=current_site.name site_domain=current_site.domain %} 5 | Hello, {{ user_display }}, 6 | 7 | Thank you for registering your e-mail to {{ site_name }}. 8 | Please click the following link to confirm your e-mail: 9 | 10 | {{ activate_url }} 11 | {% endwith %} 12 | {% endautoescape %} 13 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_signup_message.txt: -------------------------------------------------------------------------------- 1 | {% load account %} 2 | {% user_display user as user_display %} 3 | {% autoescape off %} 4 | {% with site_name=current_site.name site_domain=current_site.domain %} 5 | Hello, {{ user_display }}, 6 | 7 | Thank you for registering an account on {{ site_name }}. 8 | Please click the following link to activate your account: 9 | 10 | {{ activate_url }} 11 | {% endwith %} 12 | {% endautoescape %} 13 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_signup_subject.txt: -------------------------------------------------------------------------------- 1 | {% load custom_tags %} 2 | {% autoescape off %} 3 | {{ config.NAME }} Account activation 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | {% load custom_tags %} 2 | {% autoescape off %} 3 | {{ config.NAME }} E-mail verification 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/password_reset_key_message.txt: -------------------------------------------------------------------------------- 1 | {% with site_domain=current_site.domain %} 2 | Hello, {{ username }}, 3 | 4 | You have requested to reset your password on {{ site_domain }}. 5 | To do so, please click the link below. 6 | If you didn't make this request, you can ignore this e-mail. 7 | 8 | {{ password_reset_url }} 9 | {% endwith %} 10 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/password_reset_key_subject.txt: -------------------------------------------------------------------------------- 1 | {% load custom_tags %} 2 | {% autoescape off %} 3 | {{ config.NAME }} Password reset 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | E-mail Verification · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

E-mail Verification

13 |
14 | {% if confirmation %} 15 | {% user_display confirmation.email_address.user as user_display %} 16 |
17 | {% with email=confirmation.email_address.email %} 18 | Please confirm that {{ email }} 19 | is the e-mail address for {{ user_display }}. 20 | {% endwith %} 21 |
22 | {% url 'account_confirm_email' confirmation.key as confirm_url %} 23 |
24 | 25 |
26 | {% else %} 27 | {% url 'account_email' as email_url %} 28 |
29 | This e-mail confirmation link has expired or is invalid. Please 30 | issue a new e-mail confirmation request. 31 |
32 | {% endif %} 33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | Password Reset · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Password Reset

13 |
14 |
Enter your e-mail address and we will send you a link to reset your password.
15 |
16 | {% for item in form %} 17 |
18 | 22 | {% if item.errors %} 23 | {% for error in item.errors %} 24 |

{{ error|escape }}

25 | {% endfor %} 26 | {% endif %} 27 |
28 | {% endfor %} 29 | {% if redirect_field_value %} 30 | 31 | {% endif %} 32 | 33 |
34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | Password Reset · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Password Reset

13 |
14 |
We have sent you an e-mail with a link to reset your password.
15 |
If you can't find the e-mail in your inbox, check your junk folder.
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block title %} 3 | 4 | 5 | New Password · {{ config.NAME }} 6 | {% endblock %} 7 | {% block robots %} 8 | 9 | {% endblock %} 10 | {% block content %} 11 |

New Password

12 | {% if not token_fail %} 13 | {% if form %} 14 |
15 |
16 | {% for item in form %} 17 |
18 | 19 | 22 | {% if item.errors %} 23 | {% for error in item.errors %} 24 |

{{ error|escape }}

25 | {% endfor %} 26 | {% endif %} 27 |
28 | {% endfor %} 29 | 30 |
31 | {% else %} 32 |
33 |
You have successfully reset your password.
34 |
Click here to sign in.
35 |
36 | {% endif %} 37 | {% else %} 38 |
39 |
Invalid password reset link. It might have already been used.
40 |
Click here to get a new link.
41 |
42 | {% endif %} 43 |
44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block title %} 3 | 4 | 5 | New Password · {{ config.NAME }} 6 | {% endblock %} 7 | {% block robots %} 8 | 9 | {% endblock %} 10 | {% block content %} 11 |

New Password

12 |
13 |
You have successfully reset your password.
14 |
Click here to sign in.
15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account user_tags %} 3 | {% block title %} 4 | 5 | 6 | Sign up · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Sign up

13 |
14 |
15 | {% for item in form %} 16 |
17 | 18 | 22 | {% if item.errors %} 23 | {% for error in item.errors %} 24 |

{{ error|escape }}

25 | {% endfor %} 26 | {% endif %} 27 |
28 | {% endfor %} 29 | {% if redirect_field_value %} 30 | 31 | {% endif %} 32 | 33 |
34 |
35 | {% get_oauth_providers as oauth_providers %} 36 | {% for oauth in oauth_providers %} 37 | {% if forloop.first %}
Or, sign up with:
{% endif %} 38 |
39 | 42 |
43 | {% endfor %} 44 |
45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/verification_sent.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | Activation Pending · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Activation Pending

13 |
14 |
15 | We have sent you an activation link to the e-mail address you provided. 16 | Please click that link to complete your registration. 17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /MangAdventure/templates/contribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MangAdventure", 3 | "description": "A simple manga hosting CMS written in Django.", 4 | "repository": { 5 | "url": "https://github.com/mangadventure/MangAdventure.git", 6 | "license": "MIT", 7 | "type": "git" 8 | }, 9 | "participate": { 10 | "home": "https://github.com/mangadventure/MangAdventure", 11 | "docs": "https://mangadventure.readthedocs.io/en/latest/" 12 | }, 13 | "bugs": { 14 | "list": "https://github.com/mangadventure/MangAdventure/issues", 15 | "report": "https://github.com/mangadventure/MangAdventure/issues/new/choose" 16 | }, 17 | "keywords": ["python", "django", "scss", "html5", "vanillajs", "manga", "reader"] 18 | } 19 | -------------------------------------------------------------------------------- /MangAdventure/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block description %} 3 | 4 | 5 | {% endblock %} 6 | {% block robots %} 7 | 8 | {% endblock %} 9 | {% block title %} 10 | 11 | Error {{ error_status }} · {{ config.NAME }} 12 | {% endblock %} 13 | {% block content %} 14 |

{{ error_message|safe }}

15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /MangAdventure/templates/flatpages/default.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load cache flatpage_tags %} 3 | {% cache 604800 info_page flatpage.title flatpage.content %} 4 | {% block breadcrumbs %} 5 | {{ request|breadcrumbs_ld:flatpage }} 6 | {% endblock %} 7 | {% block description %} 8 | {% with desc=flatpage.content|striptags %} 9 | 10 | 11 | {% endwith %} 12 | {% endblock %} 13 | {% block title %} 14 | {% with title=flatpage.title|striptags %} 15 | 16 | 17 | {{ title }} · {{ config.NAME }} 18 | {% endwith %} 19 | {% endblock %} 20 | {% block content %} 21 |

{{ flatpage.title }}

22 |
{{ flatpage.content }}
23 | {% endblock %} 24 | {% endcache %} 25 | -------------------------------------------------------------------------------- /MangAdventure/templates/footer.html: -------------------------------------------------------------------------------- 1 | {% load cache %}{% cache 604800 footer rss_url arg token %} 2 | 39 | {% endcache %} 40 | -------------------------------------------------------------------------------- /MangAdventure/templates/image-sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | {% spaceless %} 5 | {% for url in urlset %} 6 | 7 | {{ url.location }} 8 | {% if url.lastmod %}{{ url.lastmod|date:"Y-m-d" }}{% endif %} 9 | {% if url.changefreq %}{{ url.changefreq }}{% endif %} 10 | {% if url.priority %}{{ url.priority }}{% endif %} 11 | {% for img in url.item.sitemap_images %} 12 | {{ img }} 13 | {% endfor %} 14 | 15 | {% endfor %} 16 | {% endspaceless %} 17 | 18 | -------------------------------------------------------------------------------- /MangAdventure/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load cache humanize custom_tags %} 3 | {% block head_extras %} 4 | 6 | {% endblock %} 7 | {% block title %} 8 | 9 | 10 | Home · {{ config.NAME }} 11 | {% endblock %} 12 | {% block content %} 13 | {% cache 600 releases latest_releases %} 14 |

Latest Releases

15 |
16 | {% for release in latest_releases %} 17 |
18 |

19 | 20 | {{ release.series }} 21 | 22 |

23 |
24 | {{ release }} 26 | 44 |
45 |
46 | {% endfor %} 47 |
48 | {% endcache %} 49 | {% endblock %} 50 | {% block footer %} 51 | {% include 'footer.html' with rss_url='releases.rss' %} 52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /MangAdventure/templates/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "start_url": "/", 3 | "lang": "{{ lang }}", 4 | "name": "{{ name }}", 5 | "description": "{{ description }}", 6 | "background_color": "{{ background }}", 7 | "theme_color": "{{ color }}", 8 | "display": "standalone", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [{ 12 | "src": "/media/icon-192x192.webp", 13 | "sizes": "192x192", 14 | "type": "image/webp", 15 | "purpose": "any maskable" 16 | }, { 17 | "src": "/media/icon-512x512.webp", 18 | "sizes": "512x512", 19 | "type": "image/webp", 20 | "purpose": "any maskable" 21 | }], 22 | "shortcuts": [ 23 | {"name": "Latest", "url": "/"}, 24 | {"name": "Library", "url": "/reader/"}, 25 | {"name": "Search", "url": "/search/"}, 26 | {"name": "Bookmarks", "url": "/user/bookmarks/"} 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /MangAdventure/templates/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | {{ name }} 6 | Search {{ request.get_host }} 7 | {{ icon }} 8 | 9 | 10 | https://github.com/mangadventure 11 | {{ search }} 12 | {{ self }} 13 | 14 | -------------------------------------------------------------------------------- /MangAdventure/templates/socialaccount/authentication_error.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block description %} 3 | 4 | 5 | {% endblock %} 6 | {% block robots %} 7 | 8 | {% endblock %} 9 | {% block title %} 10 | 11 | OAuth Login Failure · {{ config.NAME }} 12 | {% endblock %} 13 | {% block content %} 14 |

OAuth Login Failure

15 |
16 | {{ auth_error.exception }} 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /MangAdventure/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mangadventure/MangAdventure/299e4f49fa55cec6ca4a7f2f4ebf8eefa5a56df9/MangAdventure/tests/__init__.py -------------------------------------------------------------------------------- /MangAdventure/tests/base.py: -------------------------------------------------------------------------------- 1 | from os import makedirs 2 | from shutil import rmtree 3 | 4 | from django.conf import settings 5 | from django.test import Client 6 | 7 | from pytest import mark 8 | 9 | 10 | @mark.django_db 11 | class MangadvTestBase: 12 | @classmethod 13 | def setup_class(cls): 14 | makedirs(settings.MEDIA_ROOT, exist_ok=True) 15 | 16 | def setup_method(self): 17 | self.client = Client() 18 | 19 | def teardown_method(self): 20 | pass 21 | 22 | @classmethod 23 | def teardown_class(cls): 24 | rmtree(settings.MEDIA_ROOT) 25 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_cache.py: -------------------------------------------------------------------------------- 1 | from django.core.cache.backends import memcached, redis 2 | 3 | from pytest import importorskip, raises 4 | 5 | from MangAdventure.cache import SignedPyLibMCCache, SignedRedisCache 6 | from MangAdventure.tests.base import MangadvTestBase 7 | 8 | 9 | class TestCache(MangadvTestBase): 10 | @classmethod 11 | def setup_class(cls): 12 | super().setup_class() 13 | importorskip('redis', reason='requires redis') 14 | 15 | def setup_method(self): 16 | self.client = SignedRedisCache('', {})._class(['']) 17 | self.og_client = redis.RedisCache('', {})._class(['']) 18 | 19 | def test_int(self): 20 | data = self.client._serializer.dumps(3) 21 | assert isinstance(data, int) 22 | assert self.client._serializer.loads(data) == 3 23 | 24 | def test_pickle(self): 25 | data = self.client._serializer.dumps([]) 26 | assert self.client._serializer.loads(data) == [] 27 | 28 | def test_unsigned(self): 29 | from pickle import UnpicklingError 30 | with raises(UnpicklingError): 31 | data = self.og_client._serializer.dumps([]) 32 | self.client._serializer.loads(data) 33 | 34 | 35 | class TestMemcached(MangadvTestBase): 36 | @classmethod 37 | def setup_class(cls): 38 | super().setup_class() 39 | importorskip('pylibmc', reason='requires pylimbc') 40 | 41 | def setup_method(self): 42 | self.client = SignedPyLibMCCache('', {})._class(['']) 43 | self.og_client = memcached.PyLibMCCache('', {})._class(['']) 44 | 45 | def test_str(self): 46 | data, flag = self.client.serialize('') 47 | assert flag & 23 == 16 48 | assert isinstance(data, bytes) 49 | assert self.client.deserialize(data, flag) == '' 50 | 51 | def test_pickle(self): 52 | data, flag = self.client.serialize([]) 53 | assert flag & 23 == 1 54 | assert isinstance(data, bytes) 55 | assert self.client.deserialize(data, flag) == [] 56 | 57 | def test_unsigned(self): 58 | from pickle import UnpicklingError 59 | with raises(UnpicklingError): 60 | data, flag = self.og_client.serialize([]) 61 | self.client.deserialize(data, flag) 62 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import Form 2 | 3 | from MangAdventure.forms import DiscordURLField, TwitterField 4 | 5 | 6 | class FormTest(Form): 7 | twitter = TwitterField(required=False) 8 | discord = DiscordURLField(required=False) 9 | 10 | 11 | def test_twitter_valid(): 12 | form = FormTest(data={'twitter': 'name123'}) 13 | assert form.is_valid() 14 | 15 | 16 | def test_twitter_invalid(): 17 | form = FormTest(data={'twitter': '@Test123'}) 18 | assert not form.is_valid() 19 | 20 | 21 | def test_discord_valid(): 22 | form = FormTest(data={'discord': 'https://discord.gg/abc123'}) 23 | assert form.is_valid() 24 | form = FormTest(data={'discord': 'https://discord.me/abc123'}) 25 | assert form.is_valid() 26 | 27 | 28 | def test_discord_invalid(): 29 | form = FormTest(data={'discord': 'https://other.eu/test'}) 30 | assert not form.is_valid() 31 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from django.test import Client 4 | from django.urls import reverse 5 | 6 | from MangAdventure.bad_bots import BOTS 7 | 8 | from .base import MangadvTestBase 9 | 10 | 11 | class TestBaseMiddleware(MangadvTestBase): 12 | def setup_method(self): 13 | super().setup_method() 14 | bot = BOTS[randint(0, len(BOTS) - 1)] 15 | self.bot = Client(HTTP_USER_AGENT=bot) 16 | 17 | def test_robots(self): 18 | r = self.bot.get(reverse('robots')) 19 | assert r.status_code == 200 20 | r = self.bot.get(reverse('index')) 21 | assert r.status_code == 403 22 | 23 | def test_early_data(self): 24 | r = self.client.post(reverse('index'), HTTP_EARLY_DATA='1') 25 | assert r.status_code == 425 26 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_sitemaps.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from MangAdventure.sitemaps import MiscSitemap 4 | from MangAdventure.tests.base import MangadvTestBase 5 | 6 | 7 | class TestSitemaps(MangadvTestBase): 8 | _pages = { 9 | 'index': ('/', 0.8, 'daily'), 10 | 'search': ('/search/', 0.5, 'never'), 11 | 'reader:directory': ('/reader/', 0.8, 'daily'), 12 | 'info': ('/info/', 0.5, 'never'), 13 | 'privacy': ('/privacy/', 0.5, 'never') 14 | } 15 | 16 | def setup_method(self): 17 | super().setup_method() 18 | self.sitemap = MiscSitemap() 19 | 20 | def test_items(self): 21 | assert list(self.sitemap.items()) == list(self._pages.keys()) 22 | 23 | @mark.parametrize('page', _pages.keys()) 24 | def test_page(self, page): 25 | assert self.sitemap.location(page) == self._pages[page][0] 26 | assert self.sitemap.priority(page) == self._pages[page][1] 27 | assert self.sitemap.changefreq(page) == self._pages[page][2] 28 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_storage.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from pytest import mark 4 | 5 | from MangAdventure.storage import ( 6 | CDNStorage, ProcessedStaticFilesFinder, ProcessedStaticFilesStorage 7 | ) 8 | from MangAdventure.tests.base import MangadvTestBase 9 | 10 | 11 | @mark.parametrize('name', ('COMPILED/style.css', 'styles/noscript.css')) 12 | def test_static_finder(name): 13 | finder = ProcessedStaticFilesFinder() 14 | root = settings.STATIC_ROOT 15 | location = finder.find_location(str(root / 'styles'), name, 'styles') 16 | assert location == str(root / name) 17 | 18 | 19 | class TestStaticStorage(MangadvTestBase): 20 | def setup_method(self): 21 | super().setup_method() 22 | self.storage = ProcessedStaticFilesStorage() 23 | 24 | def test_unprocessed_url(self): 25 | url = self.storage.url('styles/noscript.css') 26 | assert url == '/static/styles/noscript.css' 27 | 28 | def test_processed_url(self): 29 | url = self.storage.url('styles/style.scss') 30 | assert url == '/static/COMPILED/style.css' 31 | 32 | def test_post_process(self): 33 | files = self.storage.post_process({ 34 | 'styles/style.scss': (self.storage, 'style.scss'), 35 | 'styles/noscript.css': (self.storage, 'noscript.css') 36 | }) 37 | assert next(files)[1].endswith('/COMPILED/style.css') 38 | assert next(files)[1].rsplit('/', 1)[-1] == 'noscript.css' 39 | 40 | 41 | class TestCDNStorage(MangadvTestBase): 42 | _cdns = { 43 | 'statically': 'https://cdn.statically.io', 44 | 'weserv': 'https://images.weserv.nl', 45 | 'photon': 'https://i3.wp.com' 46 | } 47 | 48 | def setup_method(self): 49 | super().setup_method() 50 | self.storage = CDNStorage((300, 300)) 51 | 52 | @mark.parametrize('name', _cdns.keys()) 53 | def test_cdn_url(self, name): 54 | self.storage._cdn = name 55 | url = self.storage.url('test.jpeg') 56 | assert url.startswith(self._cdns[name]) 57 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from django.forms import ValidationError 2 | 3 | from pytest import raises 4 | 5 | from MangAdventure.validators import ( 6 | DiscordNameValidator, RedditNameValidator, TwitterNameValidator 7 | ) 8 | 9 | 10 | def test_discord_name(): 11 | validate = DiscordNameValidator() 12 | validate('Epic_user-123#8910') 13 | with raises(ValidationError): 14 | validate('User') 15 | with raises(ValidationError): 16 | validate('User8910') 17 | 18 | 19 | def test_reddit_name(): 20 | validate = RedditNameValidator() 21 | validate('/u/epicuser_1234') 22 | validate('/r/epicsub_1234') 23 | validate('epicuser_1234') 24 | with raises(ValidationError): 25 | validate('/u/epic-user_1234') 26 | 27 | 28 | def test_twitter_name(): 29 | validate = TwitterNameValidator() 30 | validate('Epic_user-1234') 31 | with raises(ValidationError): 32 | validate('@Epic_user-1234') 33 | -------------------------------------------------------------------------------- /MangAdventure/urls.py: -------------------------------------------------------------------------------- 1 | """The root URLconf.""" 2 | 3 | from django.contrib import admin 4 | from django.contrib.sitemaps.views import sitemap 5 | from django.urls import include, path 6 | from django.views.decorators.cache import cache_control 7 | 8 | from reader import feeds 9 | 10 | from .sitemaps import MiscSitemap 11 | from .views import contribute, index, manifest, opensearch, robots, search 12 | 13 | _sitemaps = {'sitemaps': {'main': MiscSitemap}} 14 | 15 | _sitemap = cache_control(max_age=86400, must_revalidate=True)(sitemap) 16 | 17 | #: The main URL patterns. 18 | urlpatterns = [ 19 | path('', index, name='index'), 20 | path('', include('config.urls')), 21 | path('search/', search, name='search'), 22 | path('admin-panel/', admin.site.urls), 23 | path('reader/', include('reader.urls')), 24 | path('api/', include('api.urls')), 25 | path('groups/', include('groups.urls')), 26 | path('user/', include('users.urls')), 27 | path('opensearch.xml', opensearch, name='opensearch'), 28 | path('contribute.json', contribute, name='contribute'), 29 | path('manifest.webmanifest', manifest, name='manifest'), 30 | path('robots.txt', robots, name='robots'), 31 | path('releases.atom', feeds.ReleasesAtom(), name='releases.atom'), 32 | path('releases.rss', feeds.ReleasesRSS(), name='releases.rss'), 33 | path('library.atom', feeds.LibraryAtom(), name='library.atom'), 34 | path('library.rss', feeds.LibraryAtom(), name='library.rss'), 35 | path('sitemap.xml', _sitemap, _sitemaps, name='sitemap.xml'), 36 | ] 37 | 38 | 39 | #: See :func:`MangAdventure.views.handler400`. 40 | handler400 = 'MangAdventure.views.handler400' 41 | 42 | #: See :func:`MangAdventure.views.handler403`. 43 | handler403 = 'MangAdventure.views.handler403' 44 | 45 | #: See :func:`MangAdventure.views.handler404`. 46 | handler404 = 'MangAdventure.views.handler404' 47 | 48 | #: See :func:`MangAdventure.views.handler500`. 49 | handler500 = 'MangAdventure.views.handler500' 50 | 51 | __all__ = [ 52 | 'urlpatterns', 'handler400', 53 | 'handler403', 'handler404', 'handler500', 54 | ] 55 | -------------------------------------------------------------------------------- /MangAdventure/widgets.py: -------------------------------------------------------------------------------- 1 | """Custom form widgets.""" 2 | 3 | from json import dumps 4 | from typing import Any, Dict 5 | 6 | from django.forms import Textarea 7 | 8 | 9 | class TinyMCE(Textarea): 10 | """ 11 | A textarea :class:`~django.forms.Widget` 12 | for `TinyMCE `_. 13 | 14 | :param attrs: A dictionary of the widget's attributes. 15 | """ 16 | 17 | def __init__(self, attrs: Dict[str, Any] = {}): 18 | if 'class' in attrs: # pragma: no cover 19 | attrs['class'] += ' tinymce' 20 | else: 21 | attrs['class'] = 'tinymce' 22 | # TODO: use dict union (Py3.9+) 23 | attrs.update({'cols': '75', 'rows': '15'}) 24 | mce_attrs = { 25 | 'selector': '.tinymce', 26 | 'theme': 'modern', 27 | 'relative_urls': True 28 | } 29 | for key in list(attrs): 30 | if key.startswith('mce_'): 31 | mce_attrs[key[4:]] = attrs.pop(key) 32 | attrs['data-tinymce-config'] = dumps(mce_attrs) 33 | super().__init__(attrs) 34 | 35 | class Media: 36 | extend = False 37 | js = ( 38 | 'https://cdn.jsdelivr.net/npm/tinymce@4.9.11/tinymce.min.js', 39 | 'scripts/tinymce-init.js' 40 | ) 41 | 42 | 43 | __all__ = ['TinyMCE'] 44 | -------------------------------------------------------------------------------- /MangAdventure/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI definitions.""" 2 | 3 | from os import environ as env 4 | 5 | from django.core.wsgi import get_wsgi_application 6 | 7 | env.setdefault('DJANGO_SETTINGS_MODULE', 'MangAdventure.settings') 8 | 9 | #: Django's WSGI application instance. 10 | application = get_wsgi_application() 11 | 12 | __all__ = ['application'] 13 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | """The app that handles the API.""" 2 | -------------------------------------------------------------------------------- /api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from MangAdventure.tests.base import MangadvTestBase 4 | 5 | 6 | @mark.usefixtures('django_db_setup') 7 | class APITestBase(MangadvTestBase): 8 | pass 9 | -------------------------------------------------------------------------------- /api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.core.management import call_command 4 | 5 | from pytest import fixture 6 | 7 | 8 | @fixture(scope='class') 9 | def django_db_setup(django_db_setup, django_db_blocker): 10 | fixtures_dir = Path(__file__).resolve().parent / 'fixtures' 11 | series_fixture = fixtures_dir / 'series.json' 12 | chapters_fixture = fixtures_dir / 'chapters.json' 13 | authors_artists_fixture = fixtures_dir / 'authors_artists.json' 14 | groups_fixture = fixtures_dir / 'groups.json' 15 | with django_db_blocker.unblock(): 16 | call_command('flush', '--no-input') 17 | call_command('loaddata', 'categories.xml') 18 | call_command('loaddata', str(authors_artists_fixture)) 19 | call_command('loaddata', str(groups_fixture)) 20 | call_command('loaddata', str(series_fixture)) 21 | call_command('loaddata', str(chapters_fixture)) 22 | -------------------------------------------------------------------------------- /api/tests/fixtures/authors_artists.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "reader.author", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Test Author" 7 | } 8 | }, 9 | { 10 | "model": "reader.artist", 11 | "pk": 1, 12 | "fields": { 13 | "name": "Test Artist" 14 | } 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /api/tests/fixtures/chapters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "reader.chapter", 4 | "pk": 1, 5 | "fields": { 6 | "title": "my chapter", 7 | "number": 0, 8 | "volume": 1, 9 | "series": 1, 10 | "file": "", 11 | "final": false, 12 | "published": "2019-12-31T13:36:20.264Z", 13 | "modified": "2019-12-31T13:36:20.264Z", 14 | "groups": [ 15 | 1 16 | ] 17 | } 18 | } 19 | ] 20 | -------------------------------------------------------------------------------- /api/tests/fixtures/groups.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "groups.group", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Test Group", 7 | "website": "", 8 | "description": "", 9 | "email": "", 10 | "discord": "", 11 | "twitter": "", 12 | "irc": "", 13 | "reddit": "", 14 | "logo": "test.png" 15 | } 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /api/tests/fixtures/series.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "reader.series", 4 | "pk": 1, 5 | "fields": { 6 | "title": "Test Series", 7 | "slug": "test-series", 8 | "description": "", 9 | "cover": "series/test-series/cover.png", 10 | "status": "OG", 11 | "created": "2019-12-30T13:58:18.645Z", 12 | "modified": "2019-12-30T13:58:18.645Z", 13 | "authors": [ 14 | 1 15 | ], 16 | "artists": [ 17 | 1 18 | ], 19 | "categories": [ 20 | "adventure" 21 | ] 22 | } 23 | }, 24 | { 25 | "model": "reader.series", 26 | "pk": 2, 27 | "fields": { 28 | "title": "Test Series 2", 29 | "slug": "test-series-2", 30 | "description": "", 31 | "cover": "series/test-series/cover.png", 32 | "status": "OG", 33 | "created": "2019-12-30T13:58:18.645Z", 34 | "modified": "2019-12-30T13:58:18.645Z", 35 | "authors": [], 36 | "artists": [], 37 | "categories": [] 38 | } 39 | } 40 | ] 41 | -------------------------------------------------------------------------------- /api/tests/test_v2.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.core.cache import cache 4 | from django.urls import reverse 5 | 6 | from pytest import mark 7 | 8 | from . import APITestBase 9 | 10 | # TODO: write the rest of the tests 11 | warnings.formatwarning = lambda m, *_: m + '\n' 12 | warnings.warn('API v2 tests are incomplete') 13 | 14 | 15 | class APIViewTestBase(APITestBase): 16 | def teardown_method(self): 17 | super().teardown_method() 18 | cache.clear() 19 | 20 | 21 | class TestOpenAPI(APIViewTestBase): 22 | def test_schema(self): 23 | r = self.client.get(reverse('api:v2:schema')) 24 | assert r.status_code == 200 25 | assert r.json()['info']['title'] == 'MangAdventure API' 26 | 27 | @mark.parametrize('name', ('swagger', 'redoc')) 28 | def test_old_docs(self, name): 29 | r = self.client.get(reverse(f'api:v2:{name}')) 30 | assert r.status_code == 410 31 | 32 | def test_docs(self): 33 | r = self.client.get(reverse('api:v2:rapidoc')) 34 | assert r.status_code == 200 35 | assert b'' 13 | _authors = 'authors' 14 | _artists = 'artists' 15 | _groups = 'groups' 16 | 17 | #: The URL namespace of the api.v1 app. 18 | app_name = 'v1' 19 | 20 | #: The URL patterns of the api.v1 app. 21 | urlpatterns = [ 22 | path('releases/', views.all_releases, name='releases'), 23 | path('series/', views.all_series, name='all_series'), 24 | path(f'{_series}/', views.series, name='series'), 25 | path(f'{_volume}/', views.volume, name='volume'), 26 | path(f'{_volume}//', views.chapter, name='chapter'), 27 | path(f'{_authors}/', views.all_people, name='all_authors'), 28 | path(f'{_authors}//', views.person, name='author'), 29 | path(f'{_artists}/', views.all_people, name='all_artists'), 30 | path(f'{_artists}//', views.person, name='artist'), 31 | path(f'{_groups}/', views.all_groups, name='all_groups'), 32 | path(f'{_groups}//', views.group, name='group'), 33 | path('categories/', views.categories, name='categories'), 34 | ] 35 | 36 | __all__ = ['app_name', 'urlpatterns'] 37 | -------------------------------------------------------------------------------- /api/v2/__init__.py: -------------------------------------------------------------------------------- 1 | """The second version of the API.""" 2 | -------------------------------------------------------------------------------- /api/v2/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class ApiV2Config(AppConfig): 7 | """Configuration for the api.v2 app.""" 8 | #: The name of the app. 9 | name = 'api.v2' 10 | 11 | 12 | __all__ = ['ApiV2Config'] 13 | -------------------------------------------------------------------------------- /api/v2/auth.py: -------------------------------------------------------------------------------- 1 | """Authentication & authorization utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any, Optional, Tuple 6 | 7 | from rest_framework.authentication import TokenAuthentication 8 | from rest_framework.permissions import DjangoObjectPermissions 9 | 10 | from users.models import ApiKey 11 | 12 | if TYPE_CHECKING: # pragma: no cover 13 | from rest_framework.request import Request 14 | 15 | 16 | class ApiKeyAuthentication(TokenAuthentication): 17 | """API key authentication class.""" 18 | keyword = 'X-API-Key' 19 | model = ApiKey 20 | 21 | def authenticate(self, request: Request) -> Optional[Tuple[Any, Any]]: 22 | token = request.headers.get( 23 | self.keyword, request.GET.get('api_key') 24 | ) 25 | return self.authenticate_credentials(token) if token else None 26 | 27 | 28 | class ScanlatorPermissions(DjangoObjectPermissions): 29 | """Authorization class for scanlators.""" 30 | authenticated_users_only = False 31 | 32 | 33 | __all__ = ['ApiKeyAuthentication', 'ScanlatorPermissions'] 34 | -------------------------------------------------------------------------------- /api/v2/mixins.py: -------------------------------------------------------------------------------- 1 | """API mixin classes.""" 2 | 3 | from functools import wraps 4 | from typing import Callable, Dict 5 | 6 | 7 | class CORSMixin: 8 | """Mixin that sets CORS headers.""" 9 | 10 | #: CORS headers dictionary. 11 | cors_headers = { 12 | 'Access-Control-Allow-Origin': '*', 13 | 'Access-Control-Allow-Headers': 'X-API-Key', 14 | 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' 15 | } 16 | 17 | @property 18 | def default_response_headers(self) -> Dict: 19 | allowed_methods = getattr(self, 'allowed_methods', []) 20 | renderer_classes = getattr(self, 'renderer_classes', []) 21 | headers = self.cors_headers 22 | headers['Allow'] = ', '.join(allowed_methods) 23 | if len(renderer_classes) > 1: 24 | headers['Vary'] = 'Accept' 25 | return headers 26 | 27 | @classmethod 28 | def decorator(cls, func: Callable) -> Callable: 29 | """Use the CORS headers as a view decorator.""" 30 | @wraps(func) 31 | def inner(*args, **kwargs): 32 | response = func(*args, **kwargs) 33 | # response.headers.update is not available 34 | for k, v in cls.cors_headers.items(): 35 | response.headers[k] = v 36 | return response 37 | return inner 38 | 39 | 40 | #: The allowed HTTP request methods. 41 | METHODS = ['get', 'post', 'patch', 'delete', 'head', 'options'] 42 | 43 | __all__ = ['CORSMixin', 'METHODS'] 44 | -------------------------------------------------------------------------------- /api/v2/negotiation.py: -------------------------------------------------------------------------------- 1 | """Content negotiation utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, List 6 | 7 | from rest_framework.negotiation import DefaultContentNegotiation 8 | 9 | if TYPE_CHECKING: # pragma: no cover 10 | from rest_framework.request import Request 11 | 12 | 13 | class OpenAPIContentNegotiation(DefaultContentNegotiation): 14 | """Class that fixes content negotiation for the OpenAPI schema.""" 15 | 16 | def get_accept_list(self, request: Request) -> List[str]: 17 | return super().get_accept_list(request) + [ 18 | 'application/vnd.oai.openapi+json' 19 | ] 20 | 21 | 22 | __all__ = ['OpenAPIContentNegotiation'] 23 | -------------------------------------------------------------------------------- /api/v2/pagination.py: -------------------------------------------------------------------------------- 1 | """Pagination utilities.""" 2 | 3 | from typing import Any, Dict, List 4 | 5 | from rest_framework.pagination import BasePagination, PageNumberPagination 6 | from rest_framework.response import Response 7 | 8 | 9 | class DummyPagination(BasePagination): 10 | """Dummy pagination class that simply wraps results.""" 11 | 12 | def to_html(self) -> str: 13 | return '' 14 | 15 | def paginate_queryset(self, *args, **kwargs) -> List: 16 | return list(args[0]) 17 | 18 | def get_paginated_response(self, data: Any) -> Response: 19 | return Response({'results': data}) 20 | 21 | def get_paginated_response_schema(self, schema: Dict) -> Dict: 22 | return {'type': 'object', 'properties': {'results': schema}} 23 | 24 | 25 | class PageLimitPagination(PageNumberPagination): 26 | """Pagination class that allows the user to limit the page size.""" 27 | page_size_query_param = 'limit' 28 | 29 | def get_paginated_response(self, data: Any) -> Response: 30 | return Response({ 31 | 'total': self.page.paginator.count, # type: ignore 32 | 'last': not self.page.has_next(), # type: ignore 33 | 'results': data 34 | }) 35 | 36 | def get_paginated_response_schema(self, schema: Dict) -> Dict: 37 | return { 38 | 'type': 'object', 39 | 'properties': { 40 | 'total': { 41 | 'type': 'integer', 42 | 'example': self.page_size, 43 | 'description': 'The total number of results across pages.' 44 | }, 45 | 'last': { 46 | 'type': 'boolean', 47 | 'example': False, 48 | 'description': 'Denotes whether this is the last page.' 49 | }, 50 | 'results': schema 51 | } 52 | } 53 | 54 | def get_schema_operation_parameters(self, view: Any) -> List[Dict]: 55 | params = super().get_schema_operation_parameters(view) 56 | params[0]['schema']['minimum'] = 1 57 | # TODO: use dict union (Py3.9+) 58 | params[1]['schema'].update({ 59 | 'minimum': 1, 'default': self.page_size 60 | }) 61 | return params 62 | 63 | 64 | __all__ = ['DummyPagination', 'PageLimitPagination'] 65 | -------------------------------------------------------------------------------- /api/v2/urls.py: -------------------------------------------------------------------------------- 1 | """The URLconf of the api.v2 app.""" 2 | 3 | from django.urls import include, path 4 | 5 | from rest_framework.routers import SimpleRouter 6 | 7 | from groups import api as groups_api 8 | from reader import api as reader_api 9 | from users import api as users_api 10 | 11 | from .views import openapi, rapidoc, redoc_redirect, swagger_redirect 12 | 13 | #: The API router 14 | router = SimpleRouter(trailing_slash=False) 15 | router.register('series', reader_api.SeriesViewSet, 'series') 16 | router.register('cubari', reader_api.CubariViewSet, 'cubari') 17 | router.register('chapters', reader_api.ChapterViewSet, 'chapters') 18 | router.register('artists', reader_api.ArtistViewSet, 'artists') 19 | router.register('authors', reader_api.AuthorViewSet, 'authors') 20 | router.register('categories', reader_api.CategoryViewSet, 'categories') 21 | router.register('pages', reader_api.PageViewSet, 'pages') 22 | router.register('groups', groups_api.GroupViewSet, 'groups') 23 | router.register('members', groups_api.MemberViewSet, 'members') 24 | router.register('bookmarks', users_api.BookmarkViewSet, 'bookmarks') 25 | router.register('token', users_api.ApiKeyViewSet, 'token') 26 | 27 | #: The URL namespace of the api.v2 app. 28 | app_name = 'v2' 29 | 30 | #: The URL patterns of the api.v2 app. 31 | urlpatterns = [ 32 | path('', include(router.urls)), 33 | # HACK: move profile operations to the main endpoint 34 | path('profile', users_api.ProfileViewSet.as_view()), 35 | path('openapi.json', openapi, name='schema'), 36 | path('docs/', rapidoc, name='rapidoc'), 37 | path('redoc/', redoc_redirect, name='redoc'), 38 | path('swagger/', swagger_redirect, name='swagger'), 39 | ] 40 | 41 | __all__ = ['app_name', 'urlpatterns'] 42 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | """The app that handles configuration.""" 2 | -------------------------------------------------------------------------------- /config/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration.""" 2 | 3 | from django.apps import AppConfig 4 | from django.conf import settings 5 | 6 | from PIL import Image 7 | 8 | 9 | class SiteConfig(AppConfig): 10 | """Configuration for the config app.""" 11 | name = 'config' 12 | verbose_name = 'Configuration' 13 | verbose_name_plural = 'Configuration' 14 | 15 | def ready(self): 16 | """Configure the app when it's ready.""" 17 | super().ready() 18 | 19 | # we don't need to attempt to fetch up to 21 results 20 | # to figure out if there's an error in the get query 21 | __import__('django').db.models.query.MAX_GET_RESULTS = 3 22 | 23 | # create manifest icons 24 | logo = settings.BASE_DIR / settings.CONFIG['LOGO_OG'].lstrip('/') 25 | if logo.is_file(): 26 | icon192 = settings.MEDIA_ROOT / 'icon-192x192.webp' 27 | if not icon192.exists(): 28 | img = Image.open(logo) 29 | img.thumbnail((192, 192), Image.LANCZOS) 30 | img.save(icon192, 'WEBP', lossless=True) 31 | img.close() 32 | 33 | icon512 = settings.MEDIA_ROOT / 'icon-512x512.webp' 34 | if not icon512.exists(): 35 | img = Image.open(logo) 36 | img.thumbnail((512, 512), Image.LANCZOS) 37 | img.save(icon512, 'WEBP', lossless=True) 38 | img.close() 39 | 40 | 41 | __all__ = ['SiteConfig'] 42 | -------------------------------------------------------------------------------- /config/context_processors.py: -------------------------------------------------------------------------------- 1 | """Custom context processors.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Dict, cast 6 | 7 | from django.conf import settings 8 | 9 | from MangAdventure import __version__ as VERSION 10 | from MangAdventure.jsonld import schema 11 | 12 | if TYPE_CHECKING: # pragma: no cover 13 | from django.http import HttpRequest 14 | 15 | 16 | def extra_settings(request: HttpRequest) -> Dict: 17 | """ 18 | Context processor which defines some settings variables & schemas. 19 | 20 | * ``MANGADV_VERSION``: The current version of MangAdventure. 21 | * ``PAGE_URL``: The complete absolute URI of the request. 22 | * ``CANON_URL``: The absolute URI of the request minus the query string. 23 | * ``config``: A reference to :const:`MangAdventure.settings.CONFIG`. 24 | * ``searchbox``: Searchbox JSON-LD schema. 25 | * ``organization``: Organization JSON-LD schema. 26 | 27 | :param request: The current HTTP request. 28 | 29 | :return: A dictionary containing the variables. 30 | """ 31 | full_uri = request.build_absolute_uri() 32 | base_uri = request.build_absolute_uri('/') 33 | path_uri = request.build_absolute_uri(request.path) 34 | logo_uri = request.build_absolute_uri( 35 | cast(str, settings.CONFIG['LOGO_OG']) 36 | ) 37 | searchbox = schema('WebSite', { 38 | 'url': base_uri, 39 | 'potentialAction': [{ 40 | '@type': 'SearchAction', 41 | 'target': f'{base_uri}search/?q={{query}}', 42 | 'query-input': 'required name=query' 43 | }] 44 | }) 45 | organization = schema('Organization', { 46 | 'url': base_uri, 47 | 'logo': logo_uri, 48 | 'name': settings.CONFIG['NAME'], 49 | 'description': settings.CONFIG['DESCRIPTION'], 50 | # 'email': settings.DEFAULT_FROM_EMAIL 51 | }) 52 | return { 53 | 'MANGADV_VERSION': VERSION, 54 | 'PAGE_URL': full_uri, 55 | 'CANON_URL': path_uri, 56 | 'config': settings.CONFIG, 57 | 'searchbox': searchbox, 58 | 'organization': organization 59 | } 60 | 61 | 62 | __all__ = ['extra_settings'] 63 | -------------------------------------------------------------------------------- /config/management/__init__.py: -------------------------------------------------------------------------------- 1 | """Config app management.""" 2 | -------------------------------------------------------------------------------- /config/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Config app commands.""" 2 | -------------------------------------------------------------------------------- /config/management/commands/clearcache.py: -------------------------------------------------------------------------------- 1 | """Clear cache command""" 2 | 3 | from django.core.cache import cache 4 | from django.core.management import BaseCommand 5 | 6 | 7 | class Command(BaseCommand): 8 | """Command used to clear the cache.""" 9 | help = 'Clear the cache.' 10 | 11 | def handle(self, *args: str, **options: str): 12 | """ 13 | Execute the command. 14 | 15 | :param args: The arguments of the command. 16 | :param options: The options of the command. 17 | """ 18 | cache.clear() 19 | -------------------------------------------------------------------------------- /config/management/commands/createsuperuser.py: -------------------------------------------------------------------------------- 1 | """Override the createsuperuser command.""" 2 | 3 | from django.contrib.auth.management.commands import createsuperuser 4 | from django.contrib.auth.models import User 5 | 6 | 7 | class Command(createsuperuser.Command): 8 | def handle(self, *args: str, **options: str): 9 | # HACK: disallow multiple superusers 10 | if User.objects.filter(is_superuser=True).exists(): 11 | self.stderr.write('A superuser already exists.') 12 | else: 13 | super().handle(*args, **options) 14 | -------------------------------------------------------------------------------- /config/management/commands/logs.py: -------------------------------------------------------------------------------- 1 | """Admin logs command""" 2 | 3 | from __future__ import annotations 4 | 5 | from sys import stdout 6 | from typing import TYPE_CHECKING 7 | 8 | from django.contrib.admin.models import LogEntry 9 | from django.core.exceptions import ObjectDoesNotExist 10 | from django.core.management import BaseCommand 11 | 12 | if TYPE_CHECKING: # pragma: no cover 13 | from argparse import ArgumentParser 14 | 15 | 16 | class Command(BaseCommand): 17 | """Command used to view admin logs.""" 18 | help = 'Outputs admin logs to a file or stdout.' 19 | 20 | def add_arguments(self, parser: ArgumentParser): 21 | """ 22 | Add arguments to the command. 23 | 24 | :param parser: An ``ArgumentParser`` instance. 25 | """ 26 | parser.add_argument( 27 | 'file', type=str, nargs='?', default='-', 28 | help='The file where the logs will be written.' 29 | ) 30 | 31 | def handle(self, *args: str, **options: str): 32 | """ 33 | Execute the command. 34 | 35 | :param args: The arguments of the command. 36 | :param options: The options of the command. 37 | """ 38 | out = stdout if (file := options['file']) == '-' else open(file, 'w') 39 | try: 40 | for log in LogEntry.objects.iterator(): 41 | user = log.user.username 42 | date = log.action_time.isoformat(' ', 'seconds') 43 | act = log.get_action_flag_display() 44 | try: 45 | obj = repr(log.get_edited_object()) 46 | except ObjectDoesNotExist: 47 | obj = '' 48 | msg = f'[{user}] {{{date}}} ({act}) {obj}\n' 49 | out.write(msg) # lgtm[py/clear-text-logging-sensitive-data] 50 | finally: 51 | if file != '-': 52 | out.close() 53 | -------------------------------------------------------------------------------- /config/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def create_info_pages(apps, schema_editor): 5 | from django.conf import settings 6 | apps.get_model('sites', 'Site').objects.create( 7 | pk=settings.SITE_ID, 8 | name=settings.CONFIG['NAME'], 9 | domain=settings.CONFIG['DOMAIN'] 10 | ) 11 | flatpage = apps.get_model('flatpages', 'FlatPage') 12 | flatpage.objects.bulk_create([ 13 | flatpage(pk=1, url='/info/', title='About us'), 14 | flatpage(pk=2, url='/privacy/', title='Privacy') 15 | ]) 16 | through = flatpage.sites.through 17 | through.objects.bulk_create([ 18 | through(flatpage_id=1, site_id=settings.SITE_ID), 19 | through(flatpage_id=2, site_id=settings.SITE_ID) 20 | ]) 21 | 22 | 23 | def remove_info_pages(apps, schema_editor): 24 | from django.conf import settings 25 | apps.get_model('flatpages', 'FlatPage').objects.all().delete() 26 | apps.get_model('sites', 'Site').get(pk=settings.SITE_ID).delete() 27 | 28 | 29 | class Migration(migrations.Migration): 30 | initial = True 31 | 32 | dependencies = [('flatpages', '0001_initial')] 33 | 34 | operations = [migrations.RunPython(create_info_pages, remove_info_pages)] 35 | -------------------------------------------------------------------------------- /config/migrations/0002_scanlator_permissions.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def add_scanlator_group(apps, schema_editor): 5 | group = apps.get_model('auth', 'Group') 6 | permission = apps.get_model('auth', 'Permission') 7 | scanlator = group.objects.create(name='Scanlator') 8 | scanlator.permissions.set(permission.objects.filter( 9 | content_type__app_label__in=('reader', 'groups') 10 | )) 11 | scanlator.save() 12 | 13 | 14 | def remove_scanlator_group(apps, schema_editor): 15 | apps.get_model('auth', 'Group').objects.get(name='Scanlator').delete() 16 | 17 | 18 | class Migration(migrations.Migration): 19 | dependencies = [ 20 | ('config', '0001_initial'), 21 | ('auth', '0001_initial'), 22 | ('reader', '0004_aliases'), 23 | ('groups', '0001_squashed'), 24 | ] 25 | 26 | operations = [ 27 | migrations.RunPython(add_scanlator_group, remove_scanlator_group) 28 | ] 29 | -------------------------------------------------------------------------------- /config/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mangadventure/MangAdventure/299e4f49fa55cec6ca4a7f2f4ebf8eefa5a56df9/config/migrations/__init__.py -------------------------------------------------------------------------------- /config/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """Config app template tags.""" 2 | -------------------------------------------------------------------------------- /config/templatetags/flatpage_tags.py: -------------------------------------------------------------------------------- 1 | """Template tags used by the flatpage template.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from django.template.defaultfilters import register 8 | 9 | from MangAdventure.jsonld import breadcrumbs 10 | 11 | from .custom_tags import jsonld 12 | 13 | if TYPE_CHECKING: # pragma: no cover 14 | from django.contrib.flatpages.models import FlatPage 15 | from django.http import HttpRequest 16 | 17 | 18 | @register.filter 19 | def breadcrumbs_ld(request: HttpRequest, page: FlatPage) -> str: 20 | """ 21 | Create a JSON-LD ``