├── .dependabot └── config.yml ├── .dockerignore ├── .env.docker ├── .env.example ├── .gitea └── workflows │ └── build.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── .python-version ├── .readthedocs.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── assets └── cbwebreader.jpg ├── build-test.ps1 ├── build.ps1 ├── cbreader ├── __init__.py ├── crontab ├── settings │ ├── __init__.py │ ├── base.py │ ├── dev.py │ └── nginx.conf ├── urls.py └── wsgi.py ├── comic ├── __init__.py ├── admin.py ├── errors.py ├── feeds.py ├── forms.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── scan_comics.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150616_1613.py │ ├── 0003_comicbook_comicpage.py │ ├── 0004_comicbook_unread.py │ ├── 0005_auto_20150625_1400.py │ ├── 0006_auto_20150625_1411.py │ ├── 0007_auto_20150626_1820.py │ ├── 0008_auto_20160331_1140.py │ ├── 0009_auto_20160331_1140.py │ ├── 0010_auto_20160331_1140.py │ ├── 0011_auto_20160331_1141.py │ ├── 0012_auto_20160401_0949.py │ ├── 0013_comicstatus_finished.py │ ├── 0014_auto_20160404_1402.py │ ├── 0015_auto_20160405_1126.py │ ├── 0016_auto_20160414_1335.py │ ├── 0017_usermisc.py │ ├── 0018_auto_20170113_1531.py │ ├── 0019_auto_20190730_1846.py │ ├── 0020_alter_directory_options.py │ ├── 0021_delete_setting.py │ ├── 0022_comicbook_thumbnail.py │ ├── 0023_directory_thumbnail.py │ ├── 0024_auto_20210422_0855.py │ ├── 0025_auto_20210506_1342.py │ ├── 0026_alter_usermisc_user.py │ ├── 0027_auto_20210506_1356.py │ ├── 0028_alter_comicpage_options.py │ ├── 0029_comicbook_directory2.py │ ├── 0030_auto_20220707_1720.py │ ├── 0031_remove_comicbook_directory.py │ ├── 0032_rename_directory2_comicbook_directory.py │ ├── 0033_alter_comicbook_directory.py │ ├── 0034_directory_parent2.py │ ├── 0035_auto_20220708_0910.py │ ├── 0036_remove_directory_parent.py │ ├── 0037_rename_parent2_directory_parent.py │ ├── 0038_alter_directory_parent.py │ ├── 0039_comicstatus_comic2.py │ ├── 0040_auto_20220721_1126.py │ ├── 0041_alter_comicstatus_comic2.py │ ├── 0042_remove_comicstatus_comic.py │ ├── 0043_rename_comic2_comicstatus_comic.py │ ├── 0044_alter_comicstatus_comic.py │ ├── 0045_comicstatus_one_per_user_per_comic.py │ ├── 0046_comicbook_one_comic_name_per_directory.py │ ├── 0047_comicstatus_updated.py │ ├── 0048_comicbook_page_count.py │ ├── 0049_populate_pages.py │ ├── 0050_delete_comicpage.py │ └── __init__.py ├── models.py ├── processing.py ├── rest.py ├── templates │ └── application.html ├── tests │ └── __init__.py ├── urls.py ├── util.py └── views.py ├── compose ├── .env.docker ├── docker-compose.yml └── nginx.conf ├── data ├── docker-compose.yml ├── entrypoint-cron.sh ├── entrypoint.sh ├── frontend ├── .gitignore ├── .jshintrc ├── README.md ├── babel.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo.png │ ├── logo.svg │ └── placeholder.png ├── src │ ├── App.vue │ ├── api │ │ └── index.js │ ├── assets │ │ └── logo.png │ ├── components │ │ ├── AddUser.vue │ │ ├── AlertMessages.vue │ │ ├── ComicCard.vue │ │ ├── ComicPaginate.vue │ │ ├── ConfirmButton.vue │ │ ├── HistoryTable.vue │ │ ├── InitialSetup.vue │ │ ├── TheAccountForm.vue │ │ ├── TheBreadcrumbs.vue │ │ ├── TheComicList.vue │ │ ├── TheComicReader.vue │ │ ├── TheNavbar.vue │ │ ├── TheRecentTable.vue │ │ ├── UserEdit.vue │ │ └── UserList.vue │ ├── main.js │ ├── router │ │ └── index.js │ ├── store │ │ └── index.js │ └── views │ │ ├── AboutView.vue │ │ ├── AccountView.vue │ │ ├── BrowseView.vue │ │ ├── HistoryView.vue │ │ ├── LoginView.vue │ │ ├── ReadView.vue │ │ ├── RecentView.vue │ │ └── UserView.vue ├── tsconfig.json ├── vue.config.js ├── webpack.dev.js └── webpack.prod.js ├── icons ├── 1.svg ├── 2.svg ├── 3.svg └── 4.svg ├── manage.py ├── mypy.ini ├── placehoder.xcf ├── pyproject.toml ├── pytest ├── setup.cfg ├── setup.py ├── static ├── favicon.ico └── img │ ├── ccbysa.png │ ├── logo.svg │ └── placeholder.png ├── test_comics ├── test1.rar ├── test2.rar ├── test3.rar ├── test4.rar └── test_folder │ └── blank.txt └── uv.lock /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | # Validated using https://dependabot.com/docs/config-file/validator/ 2 | # Configured using https://dependabot.com/docs/config-file/ 3 | 4 | version: 1 5 | update_configs: 6 | - package_manager: "python" 7 | directory: "/" 8 | update_schedule: "live" 9 | default_labels: 10 | - "Status: Review Needed" 11 | - "Scope: Backend" 12 | - "Type: Dependencies" 13 | allowed_updates: 14 | - match: 15 | update_type: "security" 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dockerignore whitelist 2 | * 3 | 4 | !cbreader 5 | !comic 6 | !comic_auth 7 | !static 8 | !manage.py 9 | !pyproject.toml 10 | !setup.cfg 11 | !entrypoint.sh 12 | !entrypoint-cron.sh 13 | !requirements.txt 14 | !package-lock.json 15 | !package.json 16 | !frontend 17 | !uv.lock 18 | /frontend/node_modules 19 | /frontend/dist 20 | -------------------------------------------------------------------------------- /.env.docker: -------------------------------------------------------------------------------- 1 | # Set this or it won't work. 2 | DJANGO_SECRET_KEY= 3 | 4 | DJANGO_DEBUG=False 5 | 6 | #set this to the hostname of your server. 7 | DJANGO_ALLOWED_HOSTS=localhost 8 | 9 | DB_USER=admin 10 | # Please set a better password 11 | DB_PASS=password 12 | DB_HOST=database 13 | DB_DATABASE=cbwebreader 14 | 15 | # https://github.com/jacobian/dj-database-url 16 | DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE} 17 | 18 | #Path to your comics. 19 | COMIC_BOOK_VOLUME=/media/plex/comics 20 | 21 | STATIC_ROOT='/static' 22 | 23 | MEDIA_ROOT='/media' 24 | 25 | # This expects the office winrar unrar command line tool for windows or linux. 26 | # Will work without setting if it is in the path 27 | # UNRAR_TOOL = 'unrar.exe' 28 | 29 | # for google recaptcha 2 30 | # DJANGO_CBREADER_USE_RECAPTCHA = True 31 | # DJANGO_RECAPTCHA_PRIVATE_KEY = '' 32 | # DJANGO_RECAPTCHA_PUBLIC_KEY = '' 33 | 34 | # Comment the following if not using a reverse proxy. 35 | USE_X_FORWARDED_HOST=True -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Set this or it won't work. 2 | DJANGO_SECRET_KEY = 3 | 4 | DJANGO_DEBUG=False 5 | 6 | #set this to the hostname of your server. 7 | DJANGO_ALLOWED_HOSTS='localhost' 8 | 9 | DB_USER=admin 10 | # Please set a better password 11 | DB_PASS=password 12 | DB_HOST=database 13 | DB_DATABASE=cbwebreader 14 | 15 | # https://github.com/jacobian/dj-database-url 16 | DATABASE_URL='postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}' 17 | 18 | # Path to your comics. 19 | COMIC_BOOK_VOLUME=/path/to/comic/folder 20 | 21 | # Usually fine tp leave as is. 22 | STATIC_ROOT=/static 23 | 24 | # The path where you want to store the Thumbnail files 25 | MEDIA_ROOT='/media' 26 | 27 | # This expects the office winrar unrar command line tool for windows or linux. 28 | # Will work without setting if it is in the path 29 | # UNRAR_TOOL = 'unrar.exe' 30 | 31 | # Comment the following if not using a reverse proxy. 32 | USE_X_FORWARDED_HOST=True -------------------------------------------------------------------------------- /.gitea/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build and push image 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - name: Install Docker 12 | run: curl -fsSL https://get.docker.com | sh 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v2 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | - name: Login to Docker Hub 18 | uses: docker/login-action@v2 19 | with: 20 | username: ${{ secrets.DOCKERHUB_USERNAME }} 21 | password: ${{ secrets.DOCKERHUB_TOKEN }} 22 | - name: install pip 23 | run: apt update && apt install -y python3-pip 24 | - uses: abatilo/actions-poetry@v2 25 | with: 26 | poetry-version: "1.6.1" 27 | - uses: actions/setup-node@v3 28 | - uses: actions/checkout@v3 29 | # Extract version from Poetry 30 | - name: Get version 31 | run: echo "POETRY_VERSION=$(poetry version --short)" >> $GITHUB_ENV 32 | - name: Build and push Docker image 33 | run: | 34 | docker build -t ajurna/cbwebreader:latest -t ajurna/cbwebreader:${{ env.POETRY_VERSION }} . 35 | docker push ajurna/cbwebreader:${{ env.POETRY_VERSION }} 36 | docker push ajurna/cbwebreader:latest 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### OSX ### 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear on external disk 16 | .Spotlight-V100 17 | .Trashes 18 | 19 | # Directories potentially created on remote AFP share 20 | .AppleDB 21 | .AppleDesktop 22 | Network Trash Folder 23 | Temporary Items 24 | .apdisk 25 | 26 | 27 | ### Python ### 28 | # Byte-compiled / optimized / DLL files 29 | __pycache__/ 30 | *.py[cod] 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | env/ 38 | build/ 39 | develop-eggs/ 40 | dist/ 41 | downloads/ 42 | eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | *.egg-info/ 49 | .installed.cfg 50 | *.egg 51 | .idea/ 52 | UnRAR.exe 53 | 54 | # PyInstaller 55 | # Usually these files are written by a python script from a template 56 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 57 | *.manifest 58 | *.spec 59 | 60 | # Installer logs 61 | pip-log.txt 62 | pip-delete-this-directory.txt 63 | 64 | # Unit test / coverage reports 65 | htmlcov/ 66 | .tox/ 67 | .coverage 68 | .cache 69 | nosetests.xml 70 | coverage.xml 71 | 72 | # Translations 73 | *.mo 74 | *.pot 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | 83 | ### Django ### 84 | *.log 85 | *.pot 86 | *.pyc 87 | __pycache__/ 88 | local_settings.py 89 | media 90 | 91 | .env 92 | db.sqlite3 93 | identifier.sqlite 94 | .dmypy.json 95 | node_modules 96 | localhost-key.pem 97 | localhost.pem 98 | webpack-stats.json 99 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.3.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: https://github.com/pycqa/flake8 12 | rev: "5.0.4" 13 | hooks: 14 | - id: flake8 15 | additional_dependencies: [ 16 | 'flake8-annotations==2.9.1', 17 | ] 18 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | max-line-length=120 3 | ignore-paths=.*/migrations 4 | load-plugins = pylint_django 5 | disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring 6 | good-names=pk 7 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # File: .readthedocs.yaml 2 | version: 2 3 | 4 | # Explicitly set the version of Python and its requirements 5 | python: 6 | version: 3.8 7 | install: 8 | - requirements: docs/requirements.txt 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim-bookworm 2 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 3 | 4 | ENV PYTHONFAULTHANDLER=1 \ 5 | PYTHONHASHSEED=random \ 6 | PYTHONUNBUFFERED=1 \ 7 | PYTHONDONTWRITEBYTECODE=1 \ 8 | PIP_DEFAULT_TIMEOUT=100 \ 9 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 10 | PIP_NO_CACHE_DIR=1 11 | 12 | RUN mkdir /src 13 | RUN mkdir /static 14 | 15 | WORKDIR /src 16 | 17 | 18 | COPY . /src/ 19 | COPY pyproject.toml /src 20 | COPY uv.lock /src 21 | 22 | RUN echo "deb http://ftp.uk.debian.org/debian bookworm non-free non-free-firmware" > /etc/apt/sources.list.d/non-free.list 23 | 24 | 25 | RUN apt update \ 26 | && apt install -y software-properties-common \ 27 | && apt-add-repository non-free \ 28 | && apt update \ 29 | && apt install -y npm cron unrar libmariadb-dev libpq-dev pkg-config \ 30 | && uv sync --frozen \ 31 | && cd frontend \ 32 | && npm install \ 33 | && npm run build \ 34 | && apt remove -y npm software-properties-common pkg-config swig \ 35 | && rm -r node_modules \ 36 | && apt -y auto-remove \ 37 | && apt clean \ 38 | && rm -rf /var/lib/apt/lists/* 39 | 40 | RUN cat /src/cbreader/crontab >> /etc/cron.daily/cbreader 41 | 42 | EXPOSE 8000 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CBWebReader 2 | 3 | CBWebReader is web-based CBR and CBZ file reader implemented in Django with a Vue frontend. The application allows a user to host their collection of digital comics (CBR, CBZ and PDF formats) as a remotely accessible collection. 4 | 5 | ![CBWebReader Screenshot](assets/cbwebreader.jpg) 6 | 7 | ## Core technologies 8 | 9 | The following technologies will aid development by ensuring a consistent development environment for all developers: 10 | 11 | - [docker](https://www.docker.com/get-started) 12 | - [docker-compose](https://docs.docker.com/compose/gettingstarted/) 13 | 14 | The primary frameworks and tool's are used within the application: 15 | 16 | - [Django 4.0](https://www.djangoproject.com/) 17 | - [python 3.9+](https://www.python.org/) 18 | 19 | ## Getting started 20 | 21 | The CBWebReader is a Django project and follows the standard conventions for a Django application. To get started just look in the compose folder and there is an example setup. 22 | just rename the .env.docker file to .env and add some config details and it should work right away. 23 | 24 | ## License 25 | 26 | This is a [human-readable summary](https://creativecommons.org/licenses/by-sa/4.0/) of (and not a substitute for) the [Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) License]("https://creativecommons.org/licenses/by-sa/4.0/legalcode"). 27 | -------------------------------------------------------------------------------- /assets/cbwebreader.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/assets/cbwebreader.jpg -------------------------------------------------------------------------------- /build-test.ps1: -------------------------------------------------------------------------------- 1 | poetry export --without-hashes -f requirements.txt --output requirements.txt 2 | $version=poetry version -s 3 | docker build . -t ajurna/cbwebreader:beta --no-cache 4 | # docker push ajurna/cbwebreader:beta -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | $version=uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version 2 | docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version 3 | docker push ajurna/cbwebreader --all-tags 4 | -------------------------------------------------------------------------------- /cbreader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/cbreader/__init__.py -------------------------------------------------------------------------------- /cbreader/crontab: -------------------------------------------------------------------------------- 1 | */15 * * * * python /src/manage.py scan_comics --settings=cbreader.settings.base -------------------------------------------------------------------------------- /cbreader/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | -------------------------------------------------------------------------------- /cbreader/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cbreader project. 3 | """ 4 | 5 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 6 | import os 7 | from datetime import timedelta 8 | from pathlib import Path 9 | from typing import Dict, List 10 | 11 | import dj_database_url 12 | from dotenv import load_dotenv 13 | 14 | BASE_DIR = Path(__file__).parent.parent.parent 15 | 16 | load_dotenv(override=True) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", None) 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = os.getenv('DJANGO_DEBUG', False) == 'True' 26 | 27 | ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",") 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = [ 32 | "django.contrib.admin", 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.messages", 37 | "django.contrib.staticfiles", 38 | 'drf_yasg', 39 | 'webpack_loader', 40 | 'bootstrap4', 41 | "comic", 42 | 'django_extensions', 43 | 'imagekit', 44 | 'django_boost', 45 | 'sri', 46 | "corsheaders", 47 | 'django_filters', 48 | 'rest_framework', 49 | 'rest_framework_simplejwt.token_blacklist', 50 | # 'silk' 51 | ] 52 | 53 | MIDDLEWARE = [ 54 | "django.middleware.security.SecurityMiddleware", 55 | "django_permissions_policy.PermissionsPolicyMiddleware", 56 | "django.contrib.sessions.middleware.SessionMiddleware", 57 | "corsheaders.middleware.CorsMiddleware", 58 | "django.middleware.common.CommonMiddleware", 59 | "django.middleware.csrf.CsrfViewMiddleware", 60 | "django.contrib.auth.middleware.AuthenticationMiddleware", 61 | "django.contrib.messages.middleware.MessageMiddleware", 62 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 63 | 'csp.middleware.CSPMiddleware', 64 | ] 65 | 66 | ROOT_URLCONF = "cbreader.urls" 67 | 68 | 69 | WSGI_APPLICATION = "cbreader.wsgi.application" 70 | 71 | 72 | # Database 73 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 74 | 75 | DATABASE_URL = os.getenv("DATABASE_URL") 76 | 77 | if DATABASE_URL: 78 | DATABASES = {"default": dj_database_url.config(conn_max_age=500)} 79 | else: 80 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3")}} 81 | 82 | 83 | # Internationalization 84 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 85 | 86 | LANGUAGE_CODE = "en-ie" 87 | 88 | TIME_ZONE = "UTC" 89 | 90 | USE_I18N = True 91 | 92 | USE_L10N = True 93 | 94 | USE_TZ = True 95 | 96 | 97 | # Static files (CSS, JavaScript, Images) 98 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 99 | 100 | STATIC_URL = "/static/" 101 | 102 | STATICFILES_DIRS = [ 103 | Path(BASE_DIR, "static"), 104 | Path(BASE_DIR, "frontend", "dist") 105 | ] 106 | 107 | STATIC_ROOT = os.getenv('STATIC_ROOT', None) 108 | 109 | 110 | MEDIA_ROOT = os.getenv('MEDIA_ROOT', None) 111 | 112 | MEDIA_URL = '/media/' 113 | 114 | LOGIN_REDIRECT_URL = "/comic/" 115 | 116 | LOGIN_URL = "/login/" 117 | 118 | UNRAR_TOOL = os.getenv("DJANGO_UNRAR_TOOL", None) 119 | 120 | 121 | COMIC_BOOK_VOLUME = Path(os.getenv("COMIC_BOOK_VOLUME", '/comics')) 122 | 123 | if DEBUG: 124 | min_level = 'DEBUG' 125 | else: 126 | min_level = 'INFO' 127 | 128 | min_django_level = 'INFO' 129 | 130 | LOGGING = { 131 | 'version': 1, 132 | 'disable_existing_loggers': False, # keep Django's default loggers 133 | 'formatters': { 134 | # see full list of attributes here: 135 | # https://docs.python.org/3/library/logging.html#logrecord-attributes 136 | 'verbose': { 137 | 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' 138 | }, 139 | 'simple': { 140 | 'format': '%(levelname)s %(message)s' 141 | }, 142 | 'timestampthread': { 143 | 'format': "%(asctime)s [%(threadName)-12.12s] [%(levelname)-5.5s] [%(name)-20.20s] %(message)s", 144 | }, 145 | }, 146 | 'handlers': { 147 | 'logfile': { 148 | # optionally raise to INFO to not fill the log file too quickly 149 | 'level': min_level, # this level or higher goes to the log file 150 | 'class': 'logging.handlers.RotatingFileHandler', 151 | # IMPORTANT: replace with your desired logfile name! 152 | 'filename': os.path.join(BASE_DIR, 'djangoproject.log'), 153 | 'maxBytes': 50 * 10**6, # will 50 MB do? 154 | 'backupCount': 3, # keep this many extra historical files 155 | 'formatter': 'timestampthread' 156 | }, 157 | 'console': { 158 | 'level': min_level, # this level or higher goes to the console 159 | 'class': 'logging.StreamHandler', 160 | }, 161 | }, 162 | 'loggers': { 163 | 'django': { # configure all of Django's loggers 164 | 'handlers': ['logfile', 'console'], 165 | 'level': min_django_level, # this level or higher goes to the console 166 | 'propagate': False, # don't propagate further, to avoid duplication 167 | }, 168 | # root configuration – for all of our own apps 169 | # (feel free to do separate treatment for e.g. brokenapp vs. sth else) 170 | '': { 171 | 'handlers': ['logfile', 'console'], 172 | 'level': min_level, # this level or higher goes to the console, 173 | }, 174 | }, 175 | } 176 | 177 | SILK_ENABLED = False 178 | 179 | USE_X_FORWARDED_HOST = os.getenv('USE_X_FORWARDED_HOST', False) == 'True' 180 | 181 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 182 | 183 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 184 | 185 | MYLAR_API_KEY = os.getenv('MYLAR_API_KEY', None) 186 | 187 | BOOTSTRAP4 = { 188 | "javascript_url": { 189 | "url": "https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js", 190 | "integrity": "sha384-Piv4xVNRyMGpqkS2by6br4gNJ7DXjqk09RmUpJ8jgGtD7zP9yug3goQfGII0yAns", 191 | "crossorigin": "anonymous", 192 | }, 193 | } 194 | CSP_DEFAULT_SRC = ("'none'",) 195 | CSP_STYLE_SRC = ( 196 | "'self'", 197 | "'unsafe-inline'" 198 | ) 199 | CSP_IMG_SRC = ("'self'", "data:") 200 | CSP_FONT_SRC = ("'self'",) 201 | CSP_SCRIPT_SRC = ("'self'", "'unsafe-eval'", "'unsafe-inline'", "localhost:8080") 202 | CSP_CONNECT_SRC = ("'self'", "ws://localhost:8080/ws") 203 | CSP_INCLUDE_NONCE_IN = ['script-src'] 204 | CSP_SCRIPT_SRC_ATTR = ("'self'",) # "'unsafe-inline'") 205 | 206 | 207 | PERMISSIONS_POLICY: Dict[str, List] = { 208 | "accelerometer": [], 209 | "ambient-light-sensor": [], 210 | "autoplay": [], 211 | "camera": [], 212 | "display-capture": [], 213 | "document-domain": [], 214 | "encrypted-media": [], 215 | "fullscreen": [], 216 | "geolocation": [], 217 | "gyroscope": [], 218 | "magnetometer": [], 219 | "microphone": [], 220 | "midi": [], 221 | "payment": [], 222 | "usb": [], 223 | } 224 | 225 | 226 | REST_FRAMEWORK = { 227 | # Use Django's standard `django.contrib.auth` permissions, 228 | # or allow read-only access for unauthenticated users. 229 | 'DEFAULT_PERMISSION_CLASSES': [ 230 | 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' 231 | ], 232 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 233 | 'rest_framework_simplejwt.authentication.JWTAuthentication', 234 | 'rest_framework.authentication.SessionAuthentication', 235 | ) 236 | } 237 | 238 | CORS_ALLOW_ALL_ORIGINS = True 239 | SIMPLE_JWT = { 240 | "ROTATE_REFRESH_TOKENS": True, 241 | "BLACKLIST_AFTER_ROTATION": True, 242 | 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10), 243 | 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 244 | 'LEEWAY': timedelta(seconds=30), 245 | 'ALGORITHM': 'HS256', 246 | 'AUDIENCE': 'cbwebreader-users', 247 | 'ISSUER': 'cbwebreader', 248 | } 249 | 250 | FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend') 251 | 252 | TEMPLATES = [ 253 | { 254 | "BACKEND": "django.template.backends.django.DjangoTemplates", 255 | "DIRS": [], 256 | "APP_DIRS": True, 257 | "OPTIONS": { 258 | "context_processors": [ 259 | "django.template.context_processors.debug", 260 | "django.template.context_processors.request", 261 | "django.contrib.auth.context_processors.auth", 262 | "django.contrib.messages.context_processors.messages", 263 | ] 264 | }, 265 | } 266 | ] 267 | 268 | WEBPACK_LOADER = { 269 | 'DEFAULT': { 270 | 'CACHE': not DEBUG, 271 | 'BUNDLE_DIR_NAME': '/bundles/', # must end with slash 272 | 'STATS_FILE': os.path.join(FRONTEND_DIR, 'webpack-stats.json'), 273 | 'INTEGRITY': not DEBUG, 274 | } 275 | } 276 | 277 | AUTH_PASSWORD_VALIDATORS = [ 278 | { 279 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 280 | }, 281 | { 282 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 283 | 'OPTIONS': { 284 | 'min_length': 9, 285 | } 286 | }, 287 | { 288 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 289 | }, 290 | { 291 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 292 | }, 293 | ] 294 | 295 | SUPPORTED_FILES = [".rar", ".zip", ".cbr", ".cbz", ".pdf"] 296 | -------------------------------------------------------------------------------- /cbreader/settings/dev.py: -------------------------------------------------------------------------------- 1 | from .base import INSTALLED_APPS, MIDDLEWARE, SILK_ENABLED 2 | 3 | INSTALLED_APPS += ["silk"] 4 | 5 | MIDDLEWARE += [ 6 | 'silk.middleware.SilkyMiddleware', 7 | ] 8 | 9 | SILK_ENABLED = True # noqa: F811 10 | 11 | SILKY_PYTHON_PROFILER = True 12 | -------------------------------------------------------------------------------- /cbreader/settings/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream cbwebreader_django { 2 | server cbwebreader:8000; 3 | } 4 | 5 | server { 6 | 7 | listen 80; 8 | 9 | location / { 10 | proxy_pass http://cbwebreader_django; 11 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 12 | proxy_set_header Host $host; 13 | proxy_redirect off; 14 | } 15 | 16 | location /static/ { 17 | alias /static/; 18 | } 19 | location /media/ { 20 | alias /media/; 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /cbreader/urls.py: -------------------------------------------------------------------------------- 1 | """cbreader URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Add an import: from blog import urls as blog_urls 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls import include 18 | from django.conf.urls.static import static 19 | from django.contrib import admin 20 | from django.urls import path, re_path 21 | from django.views.generic import TemplateView 22 | from drf_yasg import openapi 23 | from drf_yasg.views import get_schema_view 24 | from rest_framework import permissions 25 | from rest_framework.routers import DefaultRouter 26 | # from rest_framework_extensions.routers import ExtendedDefaultRouter 27 | from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView 28 | 29 | from comic import rest, feeds 30 | 31 | schema_view = get_schema_view( 32 | openapi.Info( 33 | title="CBWebReader API", 34 | default_version='v1', 35 | description="API to access your comic collection", 36 | contact=openapi.Contact(name="Ajurna", url="https://github.com/ajurna/cbwebreader"), 37 | license=openapi.License(name="MIT License"), 38 | ), 39 | public=True, 40 | permission_classes=[permissions.AllowAny] 41 | ) 42 | 43 | router = DefaultRouter() 44 | router.register(r'users', rest.UserViewSet) 45 | router.register(r'browse', rest.BrowseViewSet, basename='browse') 46 | router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail') 47 | router.register(r'read', rest.ReadViewSet, basename='read') 48 | router.register(r'read/(?P[^/.]+)/image', rest.ImageViewSet, basename='image') 49 | router.register(r'recent', rest.RecentComicsView, basename="recent") 50 | router.register(r'history', rest.HistoryViewSet, basename='history') 51 | router.register(r'action', rest.ActionViewSet, basename='action') 52 | router.register(r'account', rest.AccountViewSet, basename='account') 53 | router.register(r'directory', rest.DirectoryViewSet, basename='directory') 54 | router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup') 55 | 56 | 57 | urlpatterns = [ 58 | path('admin/', admin.site.urls), 59 | path("feed//", feeds.RecentComicsAPI()), 60 | re_path(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), 61 | re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), 62 | re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), 63 | path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), 64 | path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), 65 | path('api/token/blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'), 66 | path('api/', include(router.urls)), 67 | path("", 68 | TemplateView.as_view(template_name="application.html"), 69 | name="app", 70 | ), 71 | ] 72 | if settings.DEBUG: 73 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 74 | # urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))] 75 | -------------------------------------------------------------------------------- /cbreader/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for cbreader project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cbreader.settings.base") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /comic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/__init__.py -------------------------------------------------------------------------------- /comic/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | 4 | from .models import Directory, ComicBook, ComicStatus, UserMisc 5 | 6 | 7 | @admin.register(Directory) 8 | class DirectoryAdmin(admin.ModelAdmin): 9 | list_display = ('id', 'name', 'parent', 'selector') 10 | raw_id_fields = ('parent',) 11 | search_fields = ('name',) 12 | 13 | 14 | @admin.register(ComicBook) 15 | class ComicBookAdmin(admin.ModelAdmin): 16 | list_display = ( 17 | 'id', 18 | 'file_name', 19 | 'date_added', 20 | 'directory', 21 | 'selector', 22 | 'version', 23 | ) 24 | list_filter = ('date_added',) 25 | raw_id_fields = ('directory',) 26 | search_fields = ['file_name'] 27 | 28 | 29 | @admin.register(ComicStatus) 30 | class ComicStatusAdmin(admin.ModelAdmin): 31 | list_display = ( 32 | 'id', 33 | 'user', 34 | 'comic', 35 | 'last_read_page', 36 | 'unread', 37 | 'finished', 38 | ) 39 | list_filter = ('unread', 'finished') 40 | raw_id_fields = ('user', 'comic') 41 | 42 | 43 | @admin.register(UserMisc) 44 | class UserMiscAdmin(admin.ModelAdmin): 45 | list_display = ('user', 'feed_id', 'allowed_to_read') 46 | list_filter = ('user',) 47 | -------------------------------------------------------------------------------- /comic/errors.py: -------------------------------------------------------------------------------- 1 | class NotCompatibleArchive(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /comic/feeds.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.contrib.syndication.views import Feed 3 | from django.db.models import Case, When, PositiveSmallIntegerField, F, QuerySet 4 | from django.http import HttpRequest 5 | from django.shortcuts import get_object_or_404 6 | 7 | from comic.models import ComicBook, UserMisc, Directory 8 | 9 | 10 | class RecentComicsAPI(Feed): 11 | title = "CBWebReader Recent Comics" 12 | link = "/read/" 13 | description = "Recently added Comics" 14 | user: User 15 | 16 | def get_object(self, request: HttpRequest, *args, **kwargs) -> UserMisc: 17 | user_misc = get_object_or_404(UserMisc, feed_id=kwargs["user_selector"]) 18 | self.user = user_misc.user 19 | return user_misc.user 20 | 21 | def items(self) -> QuerySet[ComicBook]: 22 | comics = ComicBook.objects.order_by("-date_added") 23 | comics = comics.annotate( 24 | classification=Case( 25 | When(directory__isnull=True, then=Directory.Classification.C_18), 26 | default=F('directory__classification'), 27 | output_field=PositiveSmallIntegerField(choices=Directory.Classification.choices) 28 | ) 29 | ) 30 | comics = comics.filter(classification__lte=self.user.usermisc.allowed_to_read) 31 | return comics[:10] 32 | 33 | def item_title(self, item: ComicBook) -> str: 34 | return item.file_name 35 | 36 | def item_description(self, item: ComicBook) -> str: 37 | return item.date_added.strftime("%a, %e %b %Y %H:%M") 38 | 39 | # item_link is only needed if NewsItem has no get_absolute_url method. 40 | def item_link(self, item: ComicBook) -> str: 41 | return f'#/read/{item.selector}/' 42 | -------------------------------------------------------------------------------- /comic/forms.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/forms.py -------------------------------------------------------------------------------- /comic/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/management/__init__.py -------------------------------------------------------------------------------- /comic/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/management/commands/__init__.py -------------------------------------------------------------------------------- /comic/management/commands/scan_comics.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.base_user import AbstractBaseUser 5 | from django.core.management.base import BaseCommand, CommandParser 6 | from loguru import logger 7 | 8 | from comic.models import Directory 9 | from comic.processing import generate_directory 10 | 11 | 12 | class Command(BaseCommand): 13 | help = "Scan directories to Update Comic DB" 14 | 15 | def __init__(self) -> None: 16 | super().__init__() 17 | self.OUTPUT = False 18 | 19 | def add_arguments(self, parser: CommandParser) -> None: 20 | parser.add_argument( 21 | '--out', 22 | action='store_true', 23 | help='Output to console', 24 | ) 25 | 26 | def handle(self, *args, **options) -> None: 27 | self.OUTPUT = options.get('out', False) 28 | self.scan_directory() 29 | 30 | def scan_directory(self, user: Optional[AbstractBaseUser] = None, directory: Optional[Directory] = None) -> None: 31 | if not user: 32 | user_model = get_user_model() 33 | user: AbstractBaseUser = user_model.objects.first() 34 | for item in generate_directory(user, directory): 35 | if item is Directory: 36 | logger.info(item) 37 | self.scan_directory(user, item) 38 | -------------------------------------------------------------------------------- /comic/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Setting", 14 | fields=[ 15 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 16 | ("name", models.CharField(max_length=50)), 17 | ("value", models.TextField()), 18 | ], 19 | ) 20 | ] 21 | -------------------------------------------------------------------------------- /comic/migrations/0002_auto_20150616_1613.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("comic", "0001_initial")] 10 | 11 | operations = [ 12 | migrations.AlterField(model_name="setting", name="name", field=models.CharField(unique=True, max_length=50)) 13 | ] 14 | -------------------------------------------------------------------------------- /comic/migrations/0003_comicbook_comicpage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("comic", "0002_auto_20150616_1613")] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="ComicBook", 14 | fields=[ 15 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 16 | ("file_name", models.CharField(unique=True, max_length=100)), 17 | ("last_read_page", models.IntegerField()), 18 | ], 19 | ), 20 | migrations.CreateModel( 21 | name="ComicPage", 22 | fields=[ 23 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 24 | ("index", models.IntegerField()), 25 | ("page_file_name", models.CharField(max_length=100)), 26 | ("content_type", models.CharField(max_length=30)), 27 | ("Comic", models.ForeignKey(to="comic.ComicBook", on_delete=models.CASCADE)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /comic/migrations/0004_comicbook_unread.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("comic", "0003_comicbook_comicpage")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="comicbook", name="unread", field=models.BooleanField(default=True), preserve_default=False 14 | ) 15 | ] 16 | -------------------------------------------------------------------------------- /comic/migrations/0005_auto_20150625_1400.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("comic", "0004_comicbook_unread")] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="ComicStatus", 15 | fields=[ 16 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 17 | ("last_read_page", models.IntegerField()), 18 | ("unread", models.BooleanField()), 19 | ], 20 | ), 21 | migrations.RemoveField(model_name="comicbook", name="last_read_page"), 22 | migrations.RemoveField(model_name="comicbook", name="unread"), 23 | migrations.AddField( 24 | model_name="comicstatus", 25 | name="comic", 26 | field=models.ForeignKey(to="comic.ComicBook", on_delete=models.CASCADE), 27 | ), 28 | migrations.AddField( 29 | model_name="comicstatus", 30 | name="user", 31 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /comic/migrations/0006_auto_20150625_1411.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("comic", "0005_auto_20150625_1400")] 10 | 11 | operations = [ 12 | migrations.AlterField(model_name="comicstatus", name="last_read_page", field=models.IntegerField(default=0)), 13 | migrations.AlterField(model_name="comicstatus", name="unread", field=models.BooleanField(default=True)), 14 | ] 15 | -------------------------------------------------------------------------------- /comic/migrations/0007_auto_20150626_1820.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("comic", "0006_auto_20150625_1411")] 10 | 11 | operations = [ 12 | migrations.AlterField(model_name="setting", name="name", field=models.CharField(unique=True, max_length=100)) 13 | ] 14 | -------------------------------------------------------------------------------- /comic/migrations/0008_auto_20160331_1140.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-03-31 10:40 3 | from __future__ import unicode_literals 4 | 5 | import datetime 6 | import uuid 7 | 8 | import django.db.models.deletion 9 | from django.db import migrations, models 10 | utc = datetime.timezone.utc 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [("comic", "0007_auto_20150626_1820")] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Directory", 19 | fields=[ 20 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 21 | ("name", models.CharField(max_length=100)), 22 | ("selector", models.UUIDField(default=uuid.uuid4, null=True)), 23 | ( 24 | "parent", 25 | models.ForeignKey( 26 | blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="comic.Directory" 27 | ), 28 | ), 29 | ], 30 | ), 31 | migrations.AddField( 32 | model_name="comicbook", 33 | name="date_added", 34 | field=models.DateTimeField( 35 | auto_now_add=True, default=datetime.datetime(2016, 3, 31, 10, 40, 30, 62170, tzinfo=utc) 36 | ), 37 | preserve_default=False, 38 | ), 39 | migrations.AddField( 40 | model_name="comicbook", name="selector", field=models.UUIDField(default=uuid.uuid4, null=True) 41 | ), 42 | migrations.AddField(model_name="comicbook", name="version", field=models.IntegerField(default=0)), 43 | migrations.AddField( 44 | model_name="comicbook", 45 | name="directory", 46 | field=models.ForeignKey( 47 | blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="comic.Directory" 48 | ), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /comic/migrations/0009_auto_20160331_1140.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-03-31 10:40 3 | from __future__ import unicode_literals 4 | 5 | import uuid 6 | 7 | from django.db import migrations 8 | 9 | 10 | def gen_uuid(apps, schema_editor): 11 | comicbook = apps.get_model("comic", "comicbook") 12 | for row in comicbook.objects.all(): 13 | row.selector = uuid.uuid4() 14 | row.save() 15 | directory = apps.get_model("comic", "directory") 16 | for row in directory.objects.all(): 17 | row.selector = uuid.uuid4() 18 | row.save() 19 | 20 | 21 | class Migration(migrations.Migration): 22 | 23 | dependencies = [("comic", "0008_auto_20160331_1140")] 24 | 25 | operations = [migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop)] 26 | -------------------------------------------------------------------------------- /comic/migrations/0010_auto_20160331_1140.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-03-31 10:40 3 | from __future__ import unicode_literals 4 | 5 | import uuid 6 | 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [("comic", "0009_auto_20160331_1140")] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="comicbook", name="selector", field=models.UUIDField(default=uuid.uuid4, unique=True) 17 | ), 18 | migrations.AlterField( 19 | model_name="directory", name="selector", field=models.UUIDField(default=uuid.uuid4, unique=True) 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /comic/migrations/0011_auto_20160331_1141.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-03-31 10:41 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("comic", "0010_auto_20160331_1140")] 11 | 12 | operations = [migrations.AlterField(model_name="comicbook", name="version", field=models.IntegerField(default=1))] 13 | -------------------------------------------------------------------------------- /comic/migrations/0012_auto_20160401_0949.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-04-01 08:49 3 | from __future__ import unicode_literals 4 | 5 | import uuid 6 | 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [("comic", "0011_auto_20160331_1141")] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="comicbook", 17 | name="selector", 18 | field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), 19 | ), 20 | migrations.AlterField( 21 | model_name="directory", 22 | name="selector", 23 | field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /comic/migrations/0013_comicstatus_finished.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-04-04 11:28 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("comic", "0012_auto_20160401_0949")] 11 | 12 | operations = [ 13 | migrations.AddField(model_name="comicstatus", name="finished", field=models.BooleanField(default=False)) 14 | ] 15 | -------------------------------------------------------------------------------- /comic/migrations/0014_auto_20160404_1402.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-04-04 13:02 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db.models import Max 7 | 8 | 9 | def set_finished(apps, schema_editor): 10 | comicstatus = apps.get_model("comic", "comicstatus") 11 | comicpage = apps.get_model("comic", "ComicPage") 12 | for row in comicstatus.objects.all(): 13 | last_page = comicpage.objects.filter(Comic=row.comic).aggregate(Max("index")) 14 | if row.last_read_page == last_page["index__max"]: 15 | row.finished = True 16 | row.save() 17 | 18 | 19 | class Migration(migrations.Migration): 20 | 21 | dependencies = [("comic", "0013_comicstatus_finished")] 22 | 23 | operations = [migrations.RunPython(set_finished, reverse_code=migrations.RunPython.noop)] 24 | -------------------------------------------------------------------------------- /comic/migrations/0015_auto_20160405_1126.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-04-05 10:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("comic", "0014_auto_20160404_1402")] 11 | 12 | operations = [ 13 | migrations.AlterField(model_name="comicbook", name="file_name", field=models.CharField(max_length=100)) 14 | ] 15 | -------------------------------------------------------------------------------- /comic/migrations/0016_auto_20160414_1335.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-04-14 12:35 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("comic", "0015_auto_20160405_1126")] 10 | 11 | operations = [ 12 | migrations.AlterField(model_name="comicpage", name="page_file_name", field=models.CharField(max_length=200)) 13 | ] 14 | -------------------------------------------------------------------------------- /comic/migrations/0017_usermisc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.3 on 2017-01-13 15:28 3 | from __future__ import unicode_literals 4 | 5 | import uuid 6 | 7 | import django.db.models.deletion 8 | from django.conf import settings 9 | from django.db import migrations, models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("comic", "0016_auto_20160414_1335")] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="UserMisc", 18 | fields=[ 19 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 20 | ("feed_id", models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), 21 | ( 22 | "user", 23 | models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), 24 | ), 25 | ], 26 | ) 27 | ] 28 | -------------------------------------------------------------------------------- /comic/migrations/0018_auto_20170113_1531.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.3 on 2017-01-13 15:31 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def gen_feeds(apps, schema_editor): 9 | user_misc = apps.get_model("comic", "UserMisc") 10 | User = apps.get_model("auth", "user") 11 | for user in User.objects.all(): 12 | user_misc.objects.create(user=user) 13 | 14 | 15 | class Migration(migrations.Migration): 16 | dependencies = [("comic", "0017_usermisc")] 17 | 18 | operations = [migrations.RunPython(gen_feeds, reverse_code=migrations.RunPython.noop)] 19 | -------------------------------------------------------------------------------- /comic/migrations/0019_auto_20190730_1846.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-07-30 18:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0018_auto_20170113_1531'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='comicbook', 15 | name='file_name', 16 | field=models.TextField(), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /comic/migrations/0020_alter_directory_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-08 15:14 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0019_auto_20190730_1846'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='directory', 15 | options={'ordering': ['name']}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /comic/migrations/0021_delete_setting.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-21 07:56 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0020_alter_directory_options'), 10 | ] 11 | 12 | operations = [ 13 | migrations.DeleteModel( 14 | name='Setting', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /comic/migrations/0022_comicbook_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-21 11:13 2 | 3 | from django.db import migrations 4 | import imagekit.models.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0021_delete_setting'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='comicbook', 16 | name='thumbnail', 17 | field=imagekit.models.fields.ProcessedImageField(null=True, upload_to='thumbs'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0023_directory_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-21 17:44 2 | 3 | from django.db import migrations 4 | import imagekit.models.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0022_comicbook_thumbnail'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='directory', 16 | name='thumbnail', 17 | field=imagekit.models.fields.ProcessedImageField(null=True, upload_to='thumbs'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0024_auto_20210422_0855.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-22 07:55 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0023_directory_thumbnail'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='comicbook', 16 | name='thumbnail_index', 17 | field=models.PositiveIntegerField(default=0), 18 | ), 19 | migrations.AddField( 20 | model_name='directory', 21 | name='thumbnail_index', 22 | field=models.PositiveIntegerField(default=0), 23 | ), 24 | migrations.AddField( 25 | model_name='directory', 26 | name='thumbnail_issue', 27 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='directory_thumbnail_issue', to='comic.comicbook'), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /comic/migrations/0025_auto_20210506_1342.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-06 12:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0024_auto_20210422_0855'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='directory', 15 | name='classification', 16 | field=models.PositiveSmallIntegerField(choices=[(0, 'G'), (1, 'PG'), (2, '12'), (3, '15'), (4, '18')], default=4), 17 | ), 18 | migrations.AddField( 19 | model_name='usermisc', 20 | name='allowed_to_read', 21 | field=models.PositiveSmallIntegerField(choices=[(0, 'G'), (1, 'PG'), (2, '12'), (3, '15'), (4, '18')], default=4), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /comic/migrations/0026_alter_usermisc_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-06 12:50 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | import django.db.models.deletion 6 | import django_boost.models.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('comic', '0025_auto_20210506_1342'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='usermisc', 19 | name='user', 20 | field=django_boost.models.fields.AutoOneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /comic/migrations/0027_auto_20210506_1356.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-06 12:56 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | import django.db.models.deletion 6 | import django_boost.models.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('comic', '0026_alter_usermisc_user'), 14 | ] 15 | 16 | operations = [ 17 | migrations.RemoveField( 18 | model_name='usermisc', 19 | name='id', 20 | ), 21 | migrations.AlterField( 22 | model_name='usermisc', 23 | name='user', 24 | field=django_boost.models.fields.AutoOneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /comic/migrations/0028_alter_comicpage_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-05-25 08:20 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0027_auto_20210506_1356'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='comicpage', 15 | options={'ordering': ['index']}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /comic/migrations/0029_comicbook_directory2.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-07 16:03 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0028_alter_comicpage_options'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='comicbook', 16 | name='directory2', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='directory2', to='comic.directory', to_field='selector'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0030_auto_20220707_1720.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-07 16:20 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards_func(apps, schema_editor): 7 | ComicBook = apps.get_model("comic", "ComicBook") 8 | for comic in ComicBook.objects.all(): 9 | if comic.directory: 10 | comic.directory2 = comic.directory 11 | comic.save() 12 | 13 | 14 | class Migration(migrations.Migration): 15 | 16 | dependencies = [ 17 | ('comic', '0029_comicbook_directory2'), 18 | ] 19 | 20 | operations = [ 21 | migrations.RunPython(forwards_func), 22 | ] 23 | -------------------------------------------------------------------------------- /comic/migrations/0031_remove_comicbook_directory.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-07 16:25 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0030_auto_20220707_1720'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='comicbook', 15 | name='directory', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /comic/migrations/0032_rename_directory2_comicbook_directory.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-07 16:25 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0031_remove_comicbook_directory'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='comicbook', 15 | old_name='directory2', 16 | new_name='directory', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /comic/migrations/0033_alter_comicbook_directory.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-07 16:32 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0032_rename_directory2_comicbook_directory'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='comicbook', 16 | name='directory', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comic.directory', to_field='selector'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0034_directory_parent2.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-08 08:09 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0033_alter_comicbook_directory'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='directory', 16 | name='parent2', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_new', to='comic.directory', to_field='selector'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0035_auto_20220708_0910.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-08 08:10 2 | 3 | from django.db import migrations 4 | 5 | def forwards_func(apps, schema_editor): 6 | Directory = apps.get_model("comic", "Directory") 7 | for directory in Directory.objects.all(): 8 | if directory.parent: 9 | directory.parent2 = directory.parent 10 | directory.save() 11 | 12 | def backwards_func(apps, schema_editor): 13 | return 14 | Directory = apps.get_model("comic", "Directory") 15 | for directory in Directory.objects.all(): 16 | if directory.parent: 17 | directory.parent2 = directory.parent 18 | directory.save() 19 | 20 | class Migration(migrations.Migration): 21 | 22 | dependencies = [ 23 | ('comic', '0034_directory_parent2'), 24 | ] 25 | 26 | operations = [ 27 | migrations.RunPython(forwards_func, backwards_func), 28 | ] 29 | -------------------------------------------------------------------------------- /comic/migrations/0036_remove_directory_parent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-08 08:12 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0035_auto_20220708_0910'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='directory', 15 | name='parent', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /comic/migrations/0037_rename_parent2_directory_parent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-08 08:14 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0036_remove_directory_parent'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='directory', 15 | old_name='parent2', 16 | new_name='parent', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /comic/migrations/0038_alter_directory_parent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-08 08:14 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0037_rename_parent2_directory_parent'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='directory', 16 | name='parent', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comic.directory', to_field='selector'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0039_comicstatus_comic2.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 10:26 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0038_alter_directory_parent'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='comicstatus', 16 | name='comic2', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comic2', to='comic.comicbook', to_field='selector'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0040_auto_20220721_1126.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 10:21 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards_func(apps, schema_editor): 7 | ComicStatus = apps.get_model("comic", "ComicStatus") 8 | for status in ComicStatus.objects.all(): 9 | status.comic2 = status.comic 10 | status.save() 11 | 12 | 13 | def backwards_func(apps, schema_editor): 14 | ComicStatus = apps.get_model("comic", "ComicStatus") 15 | for status in ComicStatus.objects.all(): 16 | status.comic = status.comic2 17 | status.save() 18 | 19 | 20 | class Migration(migrations.Migration): 21 | 22 | dependencies = [ 23 | ('comic', '0039_comicstatus_comic2'), 24 | ] 25 | 26 | operations = [ 27 | migrations.RunPython(forwards_func, backwards_func), 28 | ] 29 | -------------------------------------------------------------------------------- /comic/migrations/0041_alter_comicstatus_comic2.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 10:27 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0040_auto_20220721_1126'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='comicstatus', 16 | name='comic2', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comic2', to='comic.comicbook', to_field='selector'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0042_remove_comicstatus_comic.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 10:29 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0041_alter_comicstatus_comic2'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='comicstatus', 15 | name='comic', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /comic/migrations/0043_rename_comic2_comicstatus_comic.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 10:29 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0042_remove_comicstatus_comic'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='comicstatus', 15 | old_name='comic2', 16 | new_name='comic', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /comic/migrations/0044_alter_comicstatus_comic.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 10:29 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('comic', '0043_rename_comic2_comicstatus_comic'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='comicstatus', 16 | name='comic', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='comic.comicbook', to_field='selector'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /comic/migrations/0045_comicstatus_one_per_user_per_comic.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-22 08:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0044_alter_comicstatus_comic'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name='comicstatus', 15 | constraint=models.UniqueConstraint(fields=('user', 'comic'), name='one_per_user_per_comic'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /comic/migrations/0046_comicbook_one_comic_name_per_directory.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-07-22 11:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0045_comicstatus_one_per_user_per_comic'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name='comicbook', 15 | constraint=models.UniqueConstraint(fields=('directory', 'file_name'), name='one_comic_name_per_directory'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /comic/migrations/0047_comicstatus_updated.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-15 09:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0046_comicbook_one_comic_name_per_directory'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='comicstatus', 15 | name='updated', 16 | field=models.DateTimeField(auto_now=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /comic/migrations/0048_comicbook_page_count.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-15 09:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0047_comicstatus_updated'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='comicbook', 15 | name='page_count', 16 | field=models.IntegerField(default=0), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /comic/migrations/0049_populate_pages.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-15 09:59 2 | 3 | from django.db import migrations 4 | from django.db.models import Count 5 | 6 | 7 | def forwards_func(apps, schema_editor): 8 | books = apps.get_model("comic", "ComicBook") 9 | for book in books.objects.all().annotate(total_pages=Count('comicpage')): 10 | book.page_count = book.total_pages 11 | book.save() 12 | 13 | 14 | class Migration(migrations.Migration): 15 | 16 | dependencies = [ 17 | ('comic', '0048_comicbook_page_count'), 18 | ] 19 | 20 | operations = [ 21 | migrations.RunPython(forwards_func), 22 | ] 23 | -------------------------------------------------------------------------------- /comic/migrations/0050_delete_comicpage.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-15 15:17 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('comic', '0049_populate_pages'), 10 | ] 11 | 12 | operations = [ 13 | migrations.DeleteModel( 14 | name='ComicPage', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /comic/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/migrations/__init__.py -------------------------------------------------------------------------------- /comic/processing.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import zipfile 3 | from itertools import chain 4 | from pathlib import Path 5 | from typing import NamedTuple, List, Optional, Union 6 | 7 | import rarfile 8 | from django.conf import settings 9 | from django.contrib.auth.base_user import AbstractBaseUser 10 | from django.db.models import Count, Q, F, Case, When, PositiveSmallIntegerField, QuerySet, ExpressionWrapper, \ 11 | IntegerField 12 | 13 | from comic import models 14 | from comic.errors import NotCompatibleArchive 15 | 16 | 17 | def generate_directory(user: AbstractBaseUser, directory: Optional[models.Directory] = None) \ 18 | -> List[Union[models.Directory, models.ComicBook]]: 19 | dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME 20 | files = [] 21 | 22 | dir_db_query = models.Directory.objects.filter(parent=directory) 23 | clean_directories(dir_db_query, dir_path, directory) 24 | 25 | file_db_query = models.ComicBook.objects.filter(directory=directory) 26 | clean_files(file_db_query, user, dir_path, directory) 27 | 28 | dir_db_query = dir_db_query.annotate( 29 | total=Count('comicbook', distinct=True), 30 | progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True, 31 | comicbook__comicstatus__user=user), distinct=True), 32 | finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()), 33 | unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()), 34 | ) 35 | files.extend(dir_db_query) 36 | 37 | # Create Missing Status 38 | new_status = [models.ComicStatus(comic=file, user=user) for file in 39 | file_db_query.exclude(comicstatus__in=models.ComicStatus.objects.filter( 40 | comic__in=file_db_query, user=user))] 41 | models.ComicStatus.objects.bulk_create(new_status) 42 | 43 | file_db_query = file_db_query.annotate( 44 | progress=F('comicstatus__last_read_page') + 1, 45 | finished=F('comicstatus__finished'), 46 | unread=F('comicstatus__unread'), 47 | user=F('comicstatus__user'), 48 | classification=Case( 49 | When(directory__isnull=True, then=models.Directory.Classification.C_G), 50 | default=F('directory__classification'), 51 | output_field=PositiveSmallIntegerField(choices=models.Directory.Classification.choices) 52 | ) 53 | ).filter(Q(user__isnull=True) | Q(user=user.id)) 54 | 55 | files.extend(file_db_query) 56 | 57 | for file in chain(file_db_query, dir_db_query): 58 | if file.thumbnail and not Path(file.thumbnail.path).exists(): 59 | file.thumbnail.delete() 60 | file.save() 61 | files.sort(key=lambda x: x.title) 62 | files.sort(key=lambda x: x.type, reverse=True) 63 | return files 64 | 65 | 66 | def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional[models.Directory] = None) -> None: 67 | dir_db_set = set(Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories) 68 | dir_list = set(x for x in sorted(dir_path.glob('*')) if x.is_dir()) 69 | # Create new directories db instances 70 | for new_directory in dir_list - dir_db_set: 71 | models.Directory(name=new_directory.name, parent=directory).save() 72 | 73 | # Remove stale db instances 74 | for stale_directory in dir_db_set - dir_list: 75 | models.Directory.objects.get(name=stale_directory.name, parent=directory).delete() 76 | 77 | 78 | def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: Optional[models.Directory] = None) \ 79 | -> None: 80 | file_list = set(x for x in sorted(dir_path.glob('*')) if x.is_file()) 81 | files_db_set = set(Path(dir_path, x.file_name) for x in files) 82 | 83 | # Parse new comics 84 | books_to_add = [] 85 | for new_comic in file_list - files_db_set: 86 | if new_comic.suffix.lower() in settings.SUPPORTED_FILES: 87 | new_book = models.ComicBook(file_name=new_comic.name, directory=directory) 88 | archive, archive_type = new_book.get_archive() 89 | try: 90 | if archive_type == 'archive': 91 | new_book.page_count = len(get_archive_files(archive)) 92 | elif archive_type == 'pdf': 93 | new_book.page_count = archive.page_count 94 | except NotCompatibleArchive: 95 | pass 96 | books_to_add.append(new_book) 97 | models.ComicBook.objects.bulk_create(books_to_add) 98 | 99 | status_to_add = [] 100 | for book in books_to_add: 101 | status_to_add.append(models.ComicStatus(user=user, comic=book)) 102 | 103 | models.ComicStatus.objects.bulk_create(status_to_add) 104 | 105 | # Remove stale comic instances 106 | for stale_comic in files_db_set - file_list: 107 | models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete() 108 | 109 | 110 | class ArchiveFile(NamedTuple): 111 | file_name: str 112 | mime_type: str 113 | 114 | 115 | def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[ArchiveFile]: 116 | return [ 117 | ArchiveFile(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist()) 118 | if not x.endswith('/') and mimetypes.guess_type(x)[0] 119 | ] 120 | -------------------------------------------------------------------------------- /comic/templates/application.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load render_bundle from webpack_loader %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | CBWebReader 11 | 12 | 13 | 16 |
17 | 18 |
19 | {% render_bundle 'main' %} 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /comic/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/tests/__init__.py -------------------------------------------------------------------------------- /comic/urls.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/urls.py -------------------------------------------------------------------------------- /comic/util.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from comic.models import ComicBook, Directory 5 | 6 | 7 | @dataclass() 8 | class Breadcrumb: 9 | name: str = 'Home' 10 | selector: str = '' 11 | 12 | 13 | def generate_breadcrumbs_from_path(directory: Optional[Directory] = None, book: Optional[ComicBook] = None): 14 | output = [Breadcrumb()] 15 | if directory: 16 | folders = directory.get_path_objects() 17 | else: 18 | folders = [] 19 | for item in folders[::-1]: 20 | output.append( 21 | Breadcrumb( 22 | name=item.name, 23 | selector=item.selector 24 | ) 25 | ) 26 | if book: 27 | output.append( 28 | Breadcrumb( 29 | name=book.file_name, 30 | selector=book.selector 31 | ) 32 | ) 33 | return output 34 | -------------------------------------------------------------------------------- /comic/views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/comic/views.py -------------------------------------------------------------------------------- /compose/.env.docker: -------------------------------------------------------------------------------- 1 | # Set this or it won't work. 2 | DJANGO_SECRET_KEY= 3 | 4 | DJANGO_DEBUG=False 5 | 6 | #set this to the hostname of your server. 7 | DJANGO_ALLOWED_HOSTS=localhost 8 | 9 | DB_USER=admin 10 | # Please set a better password 11 | DB_PASS=password 12 | DB_HOST=database 13 | DB_DATABASE=cbwebreader 14 | 15 | # https://github.com/jacobian/dj-database-url 16 | DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE} 17 | 18 | #Path to your comics. 19 | COMIC_BOOK_VOLUME=/media/plex/comics 20 | 21 | STATIC_ROOT='/static' 22 | 23 | MEDIA_ROOT='/media' 24 | 25 | # This expects the office winrar unrar command line tool for windows or linux. 26 | # Will work without setting if it is in the path 27 | # UNRAR_TOOL = 'unrar.exe' 28 | 29 | # for google recaptcha 2 30 | # DJANGO_CBREADER_USE_RECAPTCHA = True 31 | # DJANGO_RECAPTCHA_PRIVATE_KEY = '' 32 | # DJANGO_RECAPTCHA_PUBLIC_KEY = '' 33 | 34 | # Comment the following if not using a reverse proxy. 35 | USE_X_FORWARDED_HOST=True -------------------------------------------------------------------------------- /compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | services: 4 | 5 | cbwebreader: 6 | image: ajurna/cbwebreader 7 | env_file: .env 8 | links: 9 | - database 10 | depends_on: 11 | database: 12 | condition: service_healthy 13 | expose: 14 | - 8000 15 | volumes: 16 | - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} 17 | - static_files:/static 18 | - media_files:/media 19 | - .env:/src/.env 20 | command: /bin/bash entrypoint.sh 21 | 22 | cbwebreader-cron: 23 | image: ajurna/cbwebreader 24 | env_file: .env 25 | links: 26 | - database 27 | depends_on: 28 | database: 29 | condition: service_healthy 30 | volumes: 31 | - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} 32 | - media_files:/media 33 | - .env:/src/.env 34 | command: /bin/bash entrypoint-cron.sh 35 | 36 | database: 37 | image: postgres:11.4-alpine 38 | expose: 39 | - 5432 40 | volumes: 41 | - ./data:/var/lib/postgresql/data 42 | healthcheck: 43 | test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"] 44 | interval: 5s 45 | timeout: 10s 46 | retries: 3 47 | environment: 48 | - POSTGRES_USER=${DB_USER} 49 | - POSTGRES_PASSWORD=${DB_PASS} 50 | - POSTGRES_DB=${DB_DATABASE} 51 | 52 | nginx: 53 | image: nginx 54 | volumes: 55 | - static_files:/static 56 | - media_files:/media 57 | - ./nginx.conf:/etc/nginx/conf.d/default.conf 58 | ports: 59 | - 1337:80 60 | depends_on: 61 | - cbwebreader 62 | volumes: 63 | static_files: 64 | media_files: -------------------------------------------------------------------------------- /compose/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream cbwebreader_django { 2 | server cbwebreader:8000; 3 | } 4 | 5 | server { 6 | 7 | listen 80; 8 | 9 | location / { 10 | proxy_pass http://cbwebreader_django; 11 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 12 | proxy_set_header Host $host; 13 | proxy_redirect off; 14 | } 15 | 16 | location /static/ { 17 | alias /static/; 18 | } 19 | location /media/ { 20 | alias /media/; 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | services: 4 | 5 | cbwebreader: 6 | build: . 7 | env_file: .env 8 | links: 9 | - database 10 | depends_on: 11 | database: 12 | condition: service_healthy 13 | expose: 14 | - 8000 15 | volumes: 16 | - ${COMIC_BOOK_VOLUME}:/comics 17 | # - c:/comics:/comics 18 | - static_files:/static 19 | - media_files:/media 20 | - .env:/src/.env 21 | command: /bin/bash /src/entrypoint.sh 22 | 23 | # cbwebreader-cron: 24 | # build: . 25 | # env_file: .env 26 | # links: 27 | # - database 28 | # depends_on: 29 | # database: 30 | # condition: service_healthy 31 | # volumes: 32 | # - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME} 33 | # - media_files:/media 34 | # - .env:/src/.env 35 | # command: /bin/bash entrypoint-cron.sh 36 | 37 | database: 38 | image: postgres:16-alpine 39 | expose: 40 | - 5432 41 | volumes: 42 | - /var/lib/postgresql/data 43 | healthcheck: 44 | test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"] 45 | interval: 5s 46 | timeout: 10s 47 | retries: 3 48 | environment: 49 | - POSTGRES_USER=${DB_USER} 50 | - POSTGRES_PASSWORD=${DB_PASS} 51 | - POSTGRES_DB=${DB_DATABASE} 52 | 53 | nginx: 54 | image: nginx 55 | volumes: 56 | - static_files:/static 57 | - media_files:/media 58 | - ./cbreader/settings/nginx.conf:/etc/nginx/conf.d/default.conf 59 | ports: 60 | - 8337:80 61 | depends_on: 62 | - cbwebreader 63 | volumes: 64 | static_files: 65 | media_files: 66 | -------------------------------------------------------------------------------- /entrypoint-cron.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Start cron daemon. 4 | cron -f 5 | 6 | # Start application. -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | uv run manage.py migrate --settings=cbreader.settings.base 3 | 4 | uv run manage.py collectstatic --settings=cbreader.settings.base --noinput --clear 5 | 6 | uv run gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application 7 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /frontend/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 11, 3 | "asi": true 4 | } 5 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "baseUrl": "./", 6 | "moduleResolution": "node", 7 | "paths": { 8 | "@/*": [ 9 | "src/*" 10 | ] 11 | }, 12 | "lib": [ 13 | "esnext", 14 | "dom", 15 | "dom.iterable", 16 | "scripthost" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "webpack-dev-server --config webpack.dev.js", 7 | "build": "webpack --config webpack.prod.js", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@fortawesome/fontawesome-svg-core": "^6.1.2", 12 | "@fortawesome/free-solid-svg-icons": "^6.1.2", 13 | "@fortawesome/vue-fontawesome": "^3.0.1", 14 | "axios": "^1.8.4", 15 | "bootstrap": "^5.2.0", 16 | "hammerjs": "^2.0.8", 17 | "jwt-decode": "^4.0.0", 18 | "reveal.js": "^5.2.1", 19 | "timeago.js": "^4.0.2", 20 | "vue": "^3.5.13", 21 | "vue-router": "^4.0.3", 22 | "vue-toast-notification": "^3.0", 23 | "vuejs-paginate-next": "^1.0.2", 24 | "vuex": "^4.0.0", 25 | "webpack": "^5.98.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.26.10", 29 | "@vue/cli-plugin-babel": "^5.0.8", 30 | "@vue/cli-plugin-router": "^5.0.0", 31 | "@vue/cli-plugin-vuex": "^5.0.0", 32 | "@vue/cli-service": "^5.0.8", 33 | "eslint": "^9.24.0", 34 | "eslint-plugin-vue": "^10.0.0", 35 | "jshint": "^2.13.5", 36 | "mini-css-extract-plugin": "^2.9.2", 37 | "style-loader": "^4.0.0", 38 | "terser-webpack-plugin": "^5.3.14", 39 | "vue-loader": "^17.4.2", 40 | "webpack-bundle-analyzer": "^4.10.2", 41 | "webpack-bundle-tracker": "^3.1.1", 42 | "webpack-cli": "^6.0.1" 43 | }, 44 | "eslintConfig": { 45 | "root": true, 46 | "env": { 47 | "node": true 48 | }, 49 | "extends": [ 50 | "plugin:vue/vue3-essential", 51 | "eslint:recommended" 52 | ], 53 | "parserOptions": { 54 | "parser": "@babel/eslint-parser" 55 | }, 56 | "rules": {} 57 | }, 58 | "browserslist": [ 59 | "defaults" 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 42 | 44 | 56 | 59 | 60 | 62 | 63 | 64 | 68 | 73 | 78 | 83 | 88 | 93 | 98 | 99 | 110 | 111 | 114 | 116 | 119 | 120 | 122 | 126 | 130 | 134 | 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /frontend/public/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/frontend/public/placeholder.png -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 8 | -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import router from "@/router"; 3 | import store from "@/store"; 4 | import { jwtDecode } from "jwt-decode"; 5 | 6 | /** 7 | * Gets a valid access token or refreshes if needed 8 | * Uses a consistent 5-minute threshold for token expiration 9 | */ 10 | async function get_access_token() { 11 | // If we don't have tokens in the store, return null 12 | if (!store.state.jwt || !store.state.jwt.access) { 13 | return null; 14 | } 15 | 16 | try { 17 | const access = jwtDecode(store.state.jwt.access); 18 | const now = Date.now() / 1000; 19 | const refreshThreshold = 300; // 5 minutes in seconds 20 | 21 | // If token is about to expire, refresh it 22 | if (access.exp - now < refreshThreshold) { 23 | try { 24 | // Wait for the token to refresh 25 | await store.dispatch('refreshToken'); 26 | return store.state.jwt.access; 27 | } catch (error) { 28 | console.error('Failed to refresh token:', error); 29 | return null; 30 | } 31 | } 32 | 33 | return store.state.jwt.access; 34 | } catch (error) { 35 | console.error('Error decoding token:', error); 36 | return null; 37 | } 38 | } 39 | 40 | const axios_jwt = axios.create(); 41 | 42 | // Add CSRF token to all requests if using cookies for authentication 43 | axios_jwt.interceptors.request.use(function(config) { 44 | // Get CSRF token from cookie if it exists 45 | const csrfToken = document.cookie 46 | .split('; ') 47 | .find(row => row.startsWith('csrftoken=')) 48 | ?.split('=')[1]; 49 | 50 | if (csrfToken) { 51 | config.headers['X-CSRFToken'] = csrfToken; 52 | } 53 | 54 | return config; 55 | }); 56 | 57 | // Add JWT token to all requests 58 | axios_jwt.interceptors.request.use(async function (config) { 59 | const access_token = await get_access_token(); 60 | 61 | if (access_token) { 62 | config.headers.Authorization = "Bearer " + access_token; 63 | } else if (!router.currentRoute.value.fullPath.includes('login')) { 64 | // Only redirect if we're not already on the login page 65 | router.push({ 66 | name: 'login', 67 | query: { 68 | next: router.currentRoute.value.fullPath, 69 | error: 'Please log in to continue' 70 | } 71 | }); 72 | } 73 | 74 | return config; 75 | }, function (error) { 76 | return Promise.reject(error); 77 | }); 78 | 79 | export default axios_jwt 80 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/components/AddUser.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 76 | 77 | 80 | -------------------------------------------------------------------------------- /frontend/src/components/AlertMessages.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/ComicCard.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 168 | 169 | 200 | -------------------------------------------------------------------------------- /frontend/src/components/ComicPaginate.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 70 | 71 | 74 | -------------------------------------------------------------------------------- /frontend/src/components/ConfirmButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 36 | -------------------------------------------------------------------------------- /frontend/src/components/HistoryTable.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 141 | 142 | 145 | -------------------------------------------------------------------------------- /frontend/src/components/InitialSetup.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 50 | 51 | 54 | -------------------------------------------------------------------------------- /frontend/src/components/TheAccountForm.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 97 | 98 | 101 | -------------------------------------------------------------------------------- /frontend/src/components/TheBreadcrumbs.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 67 | 68 | 76 | -------------------------------------------------------------------------------- /frontend/src/components/TheComicList.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 141 | 142 | 147 | -------------------------------------------------------------------------------- /frontend/src/components/TheComicReader.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 167 | 168 | 169 | 183 | -------------------------------------------------------------------------------- /frontend/src/components/TheNavbar.vue: -------------------------------------------------------------------------------- 1 | 40 | 65 | 66 | 71 | -------------------------------------------------------------------------------- /frontend/src/components/TheRecentTable.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 180 | 181 | 186 | -------------------------------------------------------------------------------- /frontend/src/components/UserEdit.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 105 | -------------------------------------------------------------------------------- /frontend/src/components/UserList.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 37 | 38 | 41 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import * as Vue from 'vue' 2 | import App from './App.vue' 3 | import ToastPlugin from 'vue-toast-notification'; 4 | import 'vue-toast-notification/dist/theme-default.css'; 5 | 6 | import 'bootstrap/dist/css/bootstrap.min.css' 7 | import 'bootstrap/js/dist/dropdown' 8 | 9 | 10 | 11 | /* import the fontawesome core */ 12 | import { library } from '@fortawesome/fontawesome-svg-core' 13 | 14 | /* import font awesome icon component */ 15 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 16 | 17 | /* import specific icons */ 18 | import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck} from '@fortawesome/free-solid-svg-icons' 19 | library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck) 20 | 21 | import router from './router' 22 | import store from './store' 23 | 24 | Vue.createApp(App) 25 | .use(ToastPlugin) 26 | .use(store) 27 | .use(router) 28 | .component('font-awesome-icon', FontAwesomeIcon) 29 | .mount('#app') 30 | -------------------------------------------------------------------------------- /frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | import store from '@/store' 3 | 4 | const ReadView = () => import('@/views/ReadView') 5 | const RecentView = () => import('@/views/RecentView') 6 | const AccountView = () => import('@/views/AccountView') 7 | const BrowseView = () => import('@/views/BrowseView') 8 | const UserView = () => import('@/views/UserView') 9 | const LoginView = () => import('@/views/LoginView') 10 | const HistoryView = () => import('@/views/HistoryView') 11 | 12 | // Navigation guard to check if user is authenticated 13 | function requireAuth(to, from, next) { 14 | if (!store.state.jwt) { 15 | next({ 16 | name: 'login', 17 | query: { next: to.fullPath, error: 'Please log in to access this page' } 18 | }); 19 | } else { 20 | next(); 21 | } 22 | } 23 | 24 | // Navigation guard to check if user is admin 25 | function requireAdmin(to, from, next) { 26 | if (!store.state.jwt || !store.getters.is_superuser) { 27 | next({ 28 | name: 'login', 29 | query: { next: to.fullPath, error: 'Admin access required' } 30 | }); 31 | } else { 32 | next(); 33 | } 34 | } 35 | 36 | const routes = [ 37 | { 38 | path: '/', 39 | name: 'home', 40 | redirect: () => { 41 | return { name: 'browse' } 42 | } 43 | }, 44 | { 45 | path: '/browse/:selector?', 46 | name: 'browse', 47 | component: BrowseView, 48 | props: true, 49 | beforeEnter: requireAuth 50 | }, 51 | { 52 | path: '/read/:selector', 53 | name: 'read', 54 | component: ReadView, 55 | props: true, 56 | beforeEnter: requireAuth 57 | }, 58 | { 59 | path: '/login', 60 | name: 'login', 61 | component: LoginView 62 | }, 63 | { 64 | path: '/recent', 65 | name: 'recent', 66 | component: RecentView, 67 | beforeEnter: requireAuth 68 | }, 69 | { 70 | path: '/history', 71 | name: 'history', 72 | component: HistoryView, 73 | beforeEnter: requireAuth 74 | }, 75 | { 76 | path: '/account', 77 | name: 'account', 78 | component: AccountView, 79 | beforeEnter: requireAuth 80 | }, 81 | { 82 | path: '/user/:userid?', 83 | name: 'user', 84 | component: UserView, 85 | props: true, 86 | beforeEnter: requireAdmin 87 | }, 88 | { 89 | path: '/about', 90 | name: 'about', 91 | // route level code-splitting 92 | // this generates a separate chunk (about.[hash].js) for this route 93 | // which is lazy-loaded when the route is visited. 94 | component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue') 95 | } 96 | ] 97 | 98 | const router = createRouter({ 99 | history: createWebHashHistory(process.env.BASE_URL), 100 | routes 101 | }) 102 | 103 | export default router 104 | -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | import axios from 'axios' 3 | import { jwtDecode } from "jwt-decode"; 4 | import {useToast} from "vue-toast-notification"; 5 | import router from "@/router"; 6 | import api from "@/api"; 7 | 8 | // We'll no longer use localStorage for tokens 9 | // Instead, tokens will be stored in httpOnly cookies by the backend 10 | // and automatically included in requests 11 | function get_jwt_from_storage(){ 12 | return null; // Initial state will be null until login 13 | } 14 | function get_user_from_storage(){ 15 | try { 16 | return JSON.parse(localStorage.getItem('u')) 17 | } catch { 18 | return null 19 | } 20 | } 21 | 22 | export default createStore({ 23 | state: { 24 | jwt: get_jwt_from_storage(), 25 | filters: {}, 26 | user: get_user_from_storage(), 27 | classifications: [ 28 | {label: 'G', value: '0'}, 29 | {label: 'PG', value: '1'}, 30 | {label: '12', value: '2'}, 31 | {label: '15', value: '3'}, 32 | {label: '18', value: '4'}, 33 | ], 34 | }, 35 | getters: { 36 | is_superuser (state) { 37 | if (state.user === null){ 38 | return false 39 | } else { 40 | return state.user.is_superuser 41 | } 42 | } 43 | }, 44 | mutations: { 45 | updateToken(state, newToken){ 46 | // No longer storing tokens in localStorage 47 | // Tokens are stored in httpOnly cookies by the backend 48 | state.jwt = newToken; 49 | }, 50 | logOut(state){ 51 | // Clear user data from localStorage 52 | localStorage.removeItem('u') 53 | // Clear state 54 | 55 | // Make a request to the backend to invalidate the token 56 | axios.post('/api/token/blacklist/', { refresh: state.jwt?.refresh }) 57 | .catch(error => console.error('Error blacklisting token:', error)); 58 | state.jwt = null; 59 | state.user = null 60 | }, 61 | updateUser(state, userData){ 62 | localStorage.setItem('u', JSON.stringify(userData)); 63 | state.user = userData 64 | }, 65 | }, 66 | actions: { 67 | obtainToken(context, {username, password}){ 68 | const payload = { 69 | username: username, 70 | password: password 71 | } 72 | axios.post('/api/token/', payload) 73 | .then((response)=>{ 74 | context.commit('updateToken', response.data); 75 | api.get('/api/account').then(response => { 76 | context.commit('updateUser', response.data) 77 | }) 78 | if ('next' in router.currentRoute.value.query) { 79 | router.push(router.currentRoute.value.query.next) 80 | } else { 81 | router.push('browse') 82 | } 83 | 84 | }) 85 | .catch((error)=>{ 86 | const $toast = useToast(); 87 | if (error.response.data.detail) { 88 | $toast.error(error.response.data.detail, {position:'top'}); 89 | } 90 | if (error.response.data.username) { 91 | $toast.error("Username: " + error.response.data.username, {position:'top'}); 92 | } 93 | if (error.response.data.password) { 94 | $toast.error("Password: " + error.response.data.password, {position:'top'}); 95 | } 96 | 97 | }) 98 | }, 99 | refreshToken(){ 100 | // Don't attempt to refresh if we don't have a token 101 | if (!this.state.jwt || !this.state.jwt.refresh) { 102 | return Promise.reject(new Error('No refresh token available')); 103 | } 104 | 105 | const payload = { 106 | refresh: this.state.jwt.refresh 107 | } 108 | 109 | return axios.post('/api/token/refresh/', payload) 110 | .then((response) => { 111 | this.commit('updateToken', response.data); 112 | return response.data; 113 | }) 114 | .catch((error) => { 115 | console.error('Token refresh failed:', error); 116 | // If refresh fails, log the user out and redirect to login 117 | this.commit('logOut'); 118 | router.push({ 119 | name: 'login', 120 | query: { 121 | next: router.currentRoute.value.fullPath, 122 | error: 'Your session has expired. Please log in again.' 123 | } 124 | }); 125 | return Promise.reject(error); 126 | }); 127 | }, 128 | inspectToken(){ 129 | const token = this.state.jwt; 130 | if (!token) return; 131 | 132 | try { 133 | // For access token 134 | const decoded = jwtDecode(token.access); 135 | const exp = decoded.exp; 136 | const now = Date.now() / 1000; 137 | 138 | // Refresh when token is within 5 minutes of expiring 139 | const refreshThreshold = 300; // 5 minutes in seconds 140 | 141 | if (exp - now < refreshThreshold) { 142 | // Token is about to expire, refresh it 143 | this.dispatch('refreshToken'); 144 | } else if (exp < now) { 145 | // Token is already expired, force logout 146 | this.commit('logOut'); 147 | router.push({ 148 | name: 'login', 149 | query: { 150 | next: router.currentRoute.value.fullPath, 151 | error: 'Your session has expired. Please log in again.' 152 | } 153 | }); 154 | } 155 | } catch (error) { 156 | console.error('Error inspecting token:', error); 157 | // If we can't decode the token, log the user out 158 | this.commit('logOut'); 159 | router.push({name: 'login'}); 160 | } 161 | } 162 | }, 163 | modules: { 164 | } 165 | }) 166 | -------------------------------------------------------------------------------- /frontend/src/views/AboutView.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /frontend/src/views/AccountView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/views/BrowseView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 21 | -------------------------------------------------------------------------------- /frontend/src/views/HistoryView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /frontend/src/views/LoginView.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 72 | 73 | 76 | -------------------------------------------------------------------------------- /frontend/src/views/ReadView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 40 | 41 | 44 | -------------------------------------------------------------------------------- /frontend/src/views/RecentView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/views/UserView.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 83 | 84 | 87 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": false, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "noImplicitAny": false, 11 | "noLib": false, 12 | "sourceMap": true, 13 | "strict": true, 14 | "strictPropertyInitialization": false, 15 | "suppressImplicitAnyIndexErrors": true, 16 | "target": "es2015", 17 | "baseUrl": "./src" 18 | }, 19 | "exclude": [ 20 | "./node_modules" 21 | ], 22 | "include": [ 23 | "./src/**/*.ts", 24 | "./src/**/*.vue" 25 | ] 26 | } -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('@vue/cli-service') 2 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') 3 | .BundleAnalyzerPlugin; 4 | 5 | module.exports = defineConfig({ 6 | transpileDependencies: true, 7 | configureWebpack: { 8 | plugins: [new BundleAnalyzerPlugin()] 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /frontend/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { VueLoaderPlugin } = require('vue-loader') 3 | const BundleTracker = require('webpack-bundle-tracker'); 4 | const webpack = require('webpack') 5 | 6 | module.exports = () => { 7 | return { 8 | 9 | mode: 'development', 10 | devtool: 'eval-cheap-source-map', 11 | entry: path.resolve(__dirname, './src/main.js'), 12 | output: { 13 | path: path.resolve(__dirname, './dist/bundles/'), 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.vue$/, 19 | use: 'vue-loader' 20 | }, 21 | { 22 | test: /\.ts$/, 23 | loader: 'ts-loader', 24 | options: { 25 | appendTsSuffixTo: [/\.vue$/], 26 | transpileOnly: true 27 | } 28 | }, 29 | { 30 | test: /\.css$/, 31 | use: [ 32 | 'style-loader', 33 | 'css-loader' 34 | ] 35 | } 36 | ] 37 | }, 38 | resolve: { 39 | extensions: ['.ts', '.js', '.vue', '.json'], 40 | alias: { 41 | 'vue': '@vue/runtime-dom', 42 | '@': path.resolve('src'), 43 | } 44 | }, 45 | plugins: [ 46 | new VueLoaderPlugin(), 47 | new BundleTracker({ 48 | filename: 'webpack-stats.json', 49 | path: path.resolve(__dirname, './'), 50 | publicPath: 'http://localhost:8080/' 51 | }), 52 | new webpack.DefinePlugin({ 53 | 'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL), 54 | }), 55 | new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }), 56 | ], 57 | devServer: { 58 | headers: { 59 | "Access-Control-Allow-Origin":"*" 60 | }, 61 | hot: true, 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /frontend/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { VueLoaderPlugin } = require('vue-loader') 3 | const BundleTracker = require('webpack-bundle-tracker'); 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 5 | 6 | const webpack = require('webpack') 7 | 8 | 9 | module.exports = (env = {}) => { 10 | env.prod = true 11 | return { 12 | 13 | mode: 'production', 14 | devtool: 'hidden-source-map', 15 | entry: path.resolve(__dirname, './src/main.js'), 16 | output: { 17 | path: path.resolve(__dirname, './dist/bundles/'), 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.vue$/, 23 | use: 'vue-loader' 24 | }, 25 | { 26 | test: /\.ts$/, 27 | loader: 'ts-loader', 28 | options: { 29 | appendTsSuffixTo: [/\.vue$/], 30 | transpileOnly: true 31 | } 32 | }, 33 | { 34 | test: /\.css$/, 35 | use: [MiniCssExtractPlugin.loader, "css-loader"], 36 | } 37 | ] 38 | }, 39 | resolve: { 40 | extensions: ['.ts', '.js', '.vue', '.json'], 41 | alias: { 42 | 'vue': '@vue/runtime-dom', 43 | '@': path.resolve('src'), 44 | } 45 | }, 46 | plugins: [ 47 | new VueLoaderPlugin(), 48 | new BundleTracker({ 49 | filename: 'webpack-stats.json', 50 | path: path.resolve(__dirname, './'), 51 | publicPath: '/static/bundles/', 52 | integrity: true 53 | }), 54 | new webpack.DefinePlugin({ 55 | 'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL), 56 | }), 57 | new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: false }), 58 | new MiniCssExtractPlugin(), 59 | 60 | // new BundleAnalyzerPlugin(), 61 | ], 62 | optimization: { 63 | splitChunks: { 64 | chunks: 'all', 65 | }, 66 | }, 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /icons/1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 33 | 34 | 35 | 36 | 37 | 38 | WEB READER 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /icons/2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 35 | 36 | 37 | 38 | 39 | 40 | WEB READER 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /icons/3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | WEB READER 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cbreader.settings.base") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/mypy.ini -------------------------------------------------------------------------------- /placehoder.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/placehoder.xcf -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cbwebreader" 3 | version = "1.1.3" 4 | description = "CBR/Z Web Reader" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "dj-database-url>=2.3.0", 9 | "django>=5.1.7", 10 | "django-boost>=2.1", 11 | "django-bootstrap4>=25.1", 12 | "django-cors-headers>=4.7.0", 13 | "django-csp>=3.8", 14 | "django-extensions>=3.2.3", 15 | "django-filter>=25.1", 16 | "django-imagekit>=5.0.0", 17 | "django-permissions-policy>=4.25.0", 18 | "django-silk>=5.3.2", 19 | "django-sri>=0.8.0", 20 | "django-webpack-loader>=3.1.1", 21 | "djangorestframework>=3.16.0", 22 | "djangorestframework-simplejwt>=5.5.0", 23 | "drf-yasg>=1.21.10", 24 | "flake8>=7.2.0", 25 | "flake8-annotations>=3.1.1", 26 | "gunicorn>=23.0.0", 27 | "loguru>=0.7.3", 28 | "mysqlclient>=2.2.7", 29 | "pillow>=11.1.0", 30 | "psycopg2>=2.9.10", 31 | "pymupdf>=1.25.5", 32 | "python-dotenv>=1.1.0", 33 | "rarfile>=4.2", 34 | ] 35 | 36 | [dependency-groups] 37 | dev = [ 38 | "coverage>=7.8.0", 39 | "ipython>=9.0.2", 40 | "mypy>=1.15.0", 41 | "pre-commit>=4.2.0", 42 | "pylint>=3.3.6", 43 | "pylint-django>=2.6.1", 44 | "pyopenssl>=25.0.0", 45 | "werkzeug>=3.1.3", 46 | ] 47 | -------------------------------------------------------------------------------- /pytest: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pep8maxlinelength = 119 3 | python_files = tests.py test_*.py *_tests.py 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --flake8 3 | 4 | [flake8] 5 | max-line-length = 120 6 | ignore = 7 | * ANN101 8 | * ANN002 9 | * ANN003 10 | # Ignore rules which contradicts black's formatting choices: 11 | ; * E501 12 | ; * W503 13 | ; * W504 14 | ; * E266 15 | exclude = 16 | # Exclude these files 17 | */migrations/* 18 | cbreader/settings 19 | frontend 20 | 21 | 22 | [tool:isort] 23 | line_length = 119 24 | indent = ' ' 25 | multi_line_output = 3 26 | include_trailing_comma = 1 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name="cbwebreader", 5 | version="", 6 | packages=["comic", "comic.migrations", "cbreader", "comic_auth", "comic_auth.migrations"], 7 | url="https://github.com/ajurna/cbwebreader", 8 | license="http://creativecommons.org/licenses/by-sa/4.0/", 9 | author="Ajurna", 10 | author_email="ajurna@gmail.com", 11 | description="Comic Book Web Reader", 12 | ) 13 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/static/favicon.ico -------------------------------------------------------------------------------- /static/img/ccbysa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/static/img/ccbysa.png -------------------------------------------------------------------------------- /static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 42 | 44 | 56 | 59 | 60 | 62 | 63 | 64 | 68 | 73 | 78 | 83 | 88 | 93 | 98 | 99 | 110 | 111 | 114 | 116 | 119 | 120 | 122 | 126 | 130 | 134 | 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /static/img/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/static/img/placeholder.png -------------------------------------------------------------------------------- /test_comics/test1.rar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/test_comics/test1.rar -------------------------------------------------------------------------------- /test_comics/test2.rar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/test_comics/test2.rar -------------------------------------------------------------------------------- /test_comics/test3.rar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/test_comics/test3.rar -------------------------------------------------------------------------------- /test_comics/test4.rar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/test_comics/test4.rar -------------------------------------------------------------------------------- /test_comics/test_folder/blank.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajurna/cbwebreader/e5086ec6531fb80a584f7824c9304620163f9645/test_comics/test_folder/blank.txt --------------------------------------------------------------------------------