mirror of
https://github.com/ajurna/cbwebreader.git
synced 2025-12-06 14:17:19 +00:00
Compare commits
9 Commits
dependabot
...
e5086ec653
| Author | SHA1 | Date | |
|---|---|---|---|
| e5086ec653 | |||
| dd5817419b | |||
| b01eb60eeb | |||
| 306b237b01 | |||
| a7cb857c00 | |||
| 0adfba1275 | |||
| 708df71220 | |||
| 871f930727 | |||
| 3ae5b6b23b |
@@ -14,5 +14,6 @@
|
|||||||
!package-lock.json
|
!package-lock.json
|
||||||
!package.json
|
!package.json
|
||||||
!frontend
|
!frontend
|
||||||
|
!uv.lock
|
||||||
/frontend/node_modules
|
/frontend/node_modules
|
||||||
/frontend/dist
|
/frontend/dist
|
||||||
@@ -8,12 +8,6 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- repo: https://github.com/python-poetry/poetry
|
|
||||||
rev: '1.6.1' # add version here
|
|
||||||
hooks:
|
|
||||||
- id: poetry-check
|
|
||||||
- id: poetry-export
|
|
||||||
args: ["--without-hashes", "-o", "requirements.txt"]
|
|
||||||
- repo: https://github.com/pycqa/flake8
|
- repo: https://github.com/pycqa/flake8
|
||||||
rev: "5.0.4"
|
rev: "5.0.4"
|
||||||
hooks:
|
hooks:
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
17
Dockerfile
17
Dockerfile
@@ -1,4 +1,5 @@
|
|||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.13-slim-bookworm
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
ENV PYTHONFAULTHANDLER=1 \
|
ENV PYTHONFAULTHANDLER=1 \
|
||||||
PYTHONHASHSEED=random \
|
PYTHONHASHSEED=random \
|
||||||
@@ -13,12 +14,20 @@ RUN mkdir /static
|
|||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
|
||||||
COPY . /src/
|
COPY . /src/
|
||||||
|
COPY pyproject.toml /src
|
||||||
|
COPY uv.lock /src
|
||||||
|
|
||||||
|
RUN echo "deb http://ftp.uk.debian.org/debian bookworm non-free non-free-firmware" > /etc/apt/sources.list.d/non-free.list
|
||||||
|
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt update \
|
||||||
&& apt install -y npm cron unrar-free libmariadb-dev libpq-dev pkg-config swig \
|
&& apt install -y software-properties-common \
|
||||||
&& pip install --upgrade pip \
|
&& apt-add-repository non-free \
|
||||||
&& pip install -r requirements.txt \
|
&& apt update \
|
||||||
|
&& apt install -y npm cron unrar libmariadb-dev libpq-dev pkg-config \
|
||||||
|
&& uv sync --frozen \
|
||||||
&& cd frontend \
|
&& cd frontend \
|
||||||
&& npm install \
|
&& npm install \
|
||||||
&& npm run build \
|
&& npm run build \
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
poetry export --without-hashes -f requirements.txt --output requirements.txt
|
$version=uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version
|
||||||
$version=poetry version -s
|
|
||||||
docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
|
docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
|
||||||
docker push ajurna/cbwebreader --all-tags
|
docker push ajurna/cbwebreader --all-tags
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||||||
"corsheaders",
|
"corsheaders",
|
||||||
'django_filters',
|
'django_filters',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt.token_blacklist',
|
||||||
# 'silk'
|
# 'silk'
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -197,8 +198,8 @@ CSP_STYLE_SRC = (
|
|||||||
)
|
)
|
||||||
CSP_IMG_SRC = ("'self'", "data:")
|
CSP_IMG_SRC = ("'self'", "data:")
|
||||||
CSP_FONT_SRC = ("'self'",)
|
CSP_FONT_SRC = ("'self'",)
|
||||||
CSP_SCRIPT_SRC = ("'self'", "'sha256-IYBrMxCTJ62EwagLTIRncEIpWwTmoXcXkqv3KZm/Wik='")
|
CSP_SCRIPT_SRC = ("'self'", "'unsafe-eval'", "'unsafe-inline'", "localhost:8080")
|
||||||
CSP_CONNECT_SRC = ("'self'",)
|
CSP_CONNECT_SRC = ("'self'", "ws://localhost:8080/ws")
|
||||||
CSP_INCLUDE_NONCE_IN = ['script-src']
|
CSP_INCLUDE_NONCE_IN = ['script-src']
|
||||||
CSP_SCRIPT_SRC_ATTR = ("'self'",) # "'unsafe-inline'")
|
CSP_SCRIPT_SRC_ATTR = ("'self'",) # "'unsafe-inline'")
|
||||||
|
|
||||||
@@ -237,8 +238,13 @@ REST_FRAMEWORK = {
|
|||||||
CORS_ALLOW_ALL_ORIGINS = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
"ROTATE_REFRESH_TOKENS": True,
|
||||||
|
"BLACKLIST_AFTER_ROTATION": True,
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
|
||||||
'LEEWAY': timedelta(minutes=5),
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'LEEWAY': timedelta(seconds=30),
|
||||||
|
'ALGORITHM': 'HS256',
|
||||||
|
'AUDIENCE': 'cbwebreader-users',
|
||||||
|
'ISSUER': 'cbwebreader',
|
||||||
}
|
}
|
||||||
|
|
||||||
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ from django.views.generic import TemplateView
|
|||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
from drf_yasg.views import get_schema_view
|
from drf_yasg.views import get_schema_view
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework_extensions.routers import ExtendedDefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
# from rest_framework_extensions.routers import ExtendedDefaultRouter
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView
|
||||||
|
|
||||||
from comic import rest, feeds
|
from comic import rest, feeds
|
||||||
|
|
||||||
@@ -39,12 +40,12 @@ schema_view = get_schema_view(
|
|||||||
permission_classes=[permissions.AllowAny]
|
permission_classes=[permissions.AllowAny]
|
||||||
)
|
)
|
||||||
|
|
||||||
router = ExtendedDefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'users', rest.UserViewSet)
|
router.register(r'users', rest.UserViewSet)
|
||||||
router.register(r'browse', rest.BrowseViewSet, basename='browse')
|
router.register(r'browse', rest.BrowseViewSet, basename='browse')
|
||||||
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
|
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
|
||||||
router.register(r'read', rest.ReadViewSet, basename='read')\
|
router.register(r'read', rest.ReadViewSet, basename='read')
|
||||||
.register(r'image', rest.ImageViewSet, basename='image', parents_query_lookups=['selector'])
|
router.register(r'read/(?P<selector>[^/.]+)/image', rest.ImageViewSet, basename='image')
|
||||||
router.register(r'recent', rest.RecentComicsView, basename="recent")
|
router.register(r'recent', rest.RecentComicsView, basename="recent")
|
||||||
router.register(r'history', rest.HistoryViewSet, basename='history')
|
router.register(r'history', rest.HistoryViewSet, basename='history')
|
||||||
router.register(r'action', rest.ActionViewSet, basename='action')
|
router.register(r'action', rest.ActionViewSet, basename='action')
|
||||||
@@ -61,6 +62,7 @@ urlpatterns = [
|
|||||||
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||||
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('api/token/blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'),
|
||||||
path('api/', include(router.urls)),
|
path('api/', include(router.urls)),
|
||||||
path("",
|
path("",
|
||||||
TemplateView.as_view(template_name="application.html"),
|
TemplateView.as_view(template_name="application.html"),
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import uuid
|
|||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.utils.timezone import utc
|
utc = datetime.timezone.utc
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Optional, List, Union, Tuple, Final, IO
|
from typing import Optional, List, Union, Tuple, Final, IO
|
||||||
|
|
||||||
# noinspection PyPackageRequirements
|
# noinspection PyPackageRequirements
|
||||||
import fitz
|
import pymupdf
|
||||||
import rarfile
|
import rarfile
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
from PIL.Image import Image as Image_type
|
from PIL.Image import Image as Image_type
|
||||||
@@ -52,7 +52,8 @@ class Directory(models.Model):
|
|||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Directory: {self.name}; {self.parent}"
|
|
||||||
|
return f"Directory: {self.name}: {self.parent}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
@@ -141,6 +142,19 @@ class ComicBook(models.Model):
|
|||||||
return Path(base_dir, self.file_name)
|
return Path(base_dir, self.file_name)
|
||||||
|
|
||||||
def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]:
|
def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]:
|
||||||
|
if self.file_name.lower().endswith('.pdf'):
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
doc = pymupdf.open(self.get_pdf())
|
||||||
|
page: pymupdf.Page = doc[page]
|
||||||
|
pix = page.get_pixmap()
|
||||||
|
mode: Final = "RGBA" if pix.alpha else "RGB"
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
pil_data = Image.frombytes(mode, (pix.width, pix.height), pix.samples)
|
||||||
|
img = io.BytesIO()
|
||||||
|
pil_data.save(img, format="PNG")
|
||||||
|
img.seek(0)
|
||||||
|
return img, "Image/PNG"
|
||||||
|
else:
|
||||||
base_dir = settings.COMIC_BOOK_VOLUME
|
base_dir = settings.COMIC_BOOK_VOLUME
|
||||||
if self.directory:
|
if self.directory:
|
||||||
archive_path = Path(base_dir, self.directory.path, self.file_name)
|
archive_path = Path(base_dir, self.directory.path, self.file_name)
|
||||||
@@ -196,8 +210,7 @@ class ComicBook(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]:
|
def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]:
|
||||||
# noinspection PyUnresolvedReferences
|
doc = pymupdf.open(self.get_pdf())
|
||||||
doc = fitz.open(self.get_pdf())
|
|
||||||
page = doc[page_index]
|
page = doc[page_index]
|
||||||
pix = page.get_pixmap()
|
pix = page.get_pixmap()
|
||||||
mode: Final = "RGBA" if pix.alpha else "RGB"
|
mode: Final = "RGBA" if pix.alpha else "RGB"
|
||||||
@@ -239,7 +252,7 @@ class ComicBook(models.Model):
|
|||||||
return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name)
|
return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name)
|
||||||
return Path(settings.COMIC_BOOK_VOLUME, self.file_name)
|
return Path(settings.COMIC_BOOK_VOLUME, self.file_name)
|
||||||
|
|
||||||
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, fitz.Document], str]:
|
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, pymupdf.Document], str]:
|
||||||
archive_path = self.get_archive_path
|
archive_path = self.get_archive_path
|
||||||
try:
|
try:
|
||||||
return rarfile.RarFile(archive_path), 'archive'
|
return rarfile.RarFile(archive_path), 'archive'
|
||||||
@@ -252,7 +265,7 @@ class ComicBook(models.Model):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
return fitz.open(str(archive_path)), 'pdf'
|
return pymupdf.open(str(archive_path)), 'pdf'
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
raise NotCompatibleArchive
|
raise NotCompatibleArchive
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from http.client import HTTPResponse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union, Optional, Dict, Iterable, List
|
from typing import Union, Optional, Dict, Iterable, List
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -116,7 +117,7 @@ class BrowseViewSet(viewsets.GenericViewSet):
|
|||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
lookup_field = 'selector'
|
lookup_field = 'selector'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
def list(self, request: Request) -> Response:
|
def list(self, request: Request) -> Response:
|
||||||
@@ -247,7 +248,7 @@ class ReadViewSet(viewsets.GenericViewSet):
|
|||||||
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
|
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
|
||||||
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
|
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
|
||||||
@action(methods=['get'], detail=True)
|
@action(methods=['get'], detail=True)
|
||||||
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response]:
|
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response, HTTPResponse]:
|
||||||
book = models.ComicBook.objects.get(selector=selector)
|
book = models.ComicBook.objects.get(selector=selector)
|
||||||
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
||||||
try:
|
try:
|
||||||
@@ -302,8 +303,8 @@ class ImageViewSet(viewsets.ViewSet):
|
|||||||
renderer_classes = [PassthroughRenderer]
|
renderer_classes = [PassthroughRenderer]
|
||||||
|
|
||||||
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"})
|
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"})
|
||||||
def retrieve(self, _request: Request, parent_lookup_selector: UUID, page: int) -> FileResponse:
|
def retrieve(self, _request: Request, selector: UUID, page: int) -> FileResponse:
|
||||||
book = models.ComicBook.objects.get(selector=parent_lookup_selector)
|
book = models.ComicBook.objects.get(selector=selector)
|
||||||
img, content = book.get_image(int(page) - 1)
|
img, content = book.get_image(int(page) - 1)
|
||||||
self.renderer_classes[0].media_type = content
|
self.renderer_classes[0].media_type = content
|
||||||
return FileResponse(img, content_type=content)
|
return FileResponse(img, content_type=content)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
python manage.py migrate --settings=cbreader.settings.base
|
#!/usr/bin/env sh
|
||||||
|
uv run manage.py migrate --settings=cbreader.settings.base
|
||||||
|
|
||||||
python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
|
uv run manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
|
||||||
|
|
||||||
gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
|
uv run gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
|
||||||
|
|||||||
7916
frontend/package-lock.json
generated
7916
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "webpack-dev-server --config webpack.dev.js",
|
"serve": "webpack-dev-server --config webpack.dev.js",
|
||||||
@@ -11,38 +11,35 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^6.1.2",
|
"@fortawesome/fontawesome-svg-core": "^6.1.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.1.2",
|
"@fortawesome/free-solid-svg-icons": "^6.1.2",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.1",
|
"@fortawesome/vue-fontawesome": "^3.0.1",
|
||||||
"axios": "^0.27.2",
|
"axios": "^1.8.4",
|
||||||
"bootstrap": "^5.2.0",
|
"bootstrap": "^5.2.0",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^4.0.0",
|
||||||
"pdfvuer": "^2.0.1",
|
"reveal.js": "^5.2.1",
|
||||||
"reveal.js": "^4.3.1",
|
|
||||||
"timeago.js": "^4.0.2",
|
"timeago.js": "^4.0.2",
|
||||||
"vue": "^3.2.26",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.0.3",
|
"vue-router": "^4.0.3",
|
||||||
"vue-toast-notification": "3.0",
|
"vue-toast-notification": "^3.0",
|
||||||
"vuejs-paginate-next": "^1.0.2",
|
"vuejs-paginate-next": "^1.0.2",
|
||||||
"vuex": "^4.0.0",
|
"vuex": "^4.0.0",
|
||||||
"webpack": "^5.76.0"
|
"webpack": "^5.98.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.12.16",
|
"@babel/core": "^7.26.10",
|
||||||
"@babel/eslint-parser": "^7.12.16",
|
"@vue/cli-plugin-babel": "^5.0.8",
|
||||||
"@vue/cli-plugin-babel": "~5.0.0",
|
"@vue/cli-plugin-router": "^5.0.0",
|
||||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
"@vue/cli-plugin-vuex": "^5.0.0",
|
||||||
"@vue/cli-plugin-router": "~5.0.0",
|
"@vue/cli-service": "^5.0.8",
|
||||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
"eslint": "^9.24.0",
|
||||||
"@vue/cli-service": "~5.0.0",
|
"eslint-plugin-vue": "^10.0.0",
|
||||||
"eslint": "^7.32.0",
|
|
||||||
"eslint-plugin-vue": "^8.0.3",
|
|
||||||
"jshint": "^2.13.5",
|
"jshint": "^2.13.5",
|
||||||
"mini-css-extract-plugin": "^2.6.1",
|
"mini-css-extract-plugin": "^2.9.2",
|
||||||
"style-loader": "^3.3.1",
|
"style-loader": "^4.0.0",
|
||||||
"terser-webpack-plugin": "^5.3.6",
|
"terser-webpack-plugin": "^5.3.14",
|
||||||
"vue-loader": "^17.0.0",
|
"vue-loader": "^17.4.2",
|
||||||
"webpack-bundle-analyzer": "^4.6.1",
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
"webpack-bundle-tracker": "^1.6.0",
|
"webpack-bundle-tracker": "^3.1.1",
|
||||||
"webpack-cli": "^4.10.0"
|
"webpack-cli": "^6.0.1"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"root": true,
|
"root": true,
|
||||||
|
|||||||
@@ -1,39 +1,78 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import jwtDecode from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a valid access token or refreshes if needed
|
||||||
|
* Uses a consistent 5-minute threshold for token expiration
|
||||||
|
*/
|
||||||
async function get_access_token() {
|
async function get_access_token() {
|
||||||
let access = jwtDecode(store.state.jwt.access)
|
// If we don't have tokens in the store, return null
|
||||||
let refresh = jwtDecode(store.state.jwt.refresh)
|
if (!store.state.jwt || !store.state.jwt.access) {
|
||||||
if (access.exp - Date.now()/1000 < 5) {
|
return null;
|
||||||
if (refresh.exp - Date.now()/1000 < 5) {
|
}
|
||||||
await router.push({name: 'login'})
|
|
||||||
return null
|
try {
|
||||||
} else {
|
const access = jwtDecode(store.state.jwt.access);
|
||||||
return store.dispatch('refreshToken').then(() => {return store.state.jwt.access})
|
const now = Date.now() / 1000;
|
||||||
|
const refreshThreshold = 300; // 5 minutes in seconds
|
||||||
|
|
||||||
|
// If token is about to expire, refresh it
|
||||||
|
if (access.exp - now < refreshThreshold) {
|
||||||
|
try {
|
||||||
|
// Wait for the token to refresh
|
||||||
|
await store.dispatch('refreshToken');
|
||||||
|
return store.state.jwt.access;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh token:', error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return store.state.jwt.access
|
|
||||||
|
return store.state.jwt.access;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const axios_jwt = axios.create();
|
const axios_jwt = axios.create();
|
||||||
|
|
||||||
axios_jwt.interceptors.request.use(async function (config) {
|
// Add CSRF token to all requests if using cookies for authentication
|
||||||
let access_token = await get_access_token().catch(() => {
|
axios_jwt.interceptors.request.use(function(config) {
|
||||||
if (router.currentRoute.value.fullPath.includes('login')){
|
// Get CSRF token from cookie if it exists
|
||||||
router.push({name: 'login'})
|
const csrfToken = document.cookie
|
||||||
}else {
|
.split('; ')
|
||||||
router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }})
|
.find(row => row.startsWith('csrftoken='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (csrfToken) {
|
||||||
|
config.headers['X-CSRFToken'] = csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
return config;
|
||||||
config.headers = {
|
});
|
||||||
Authorization: "Bearer " + access_token
|
|
||||||
|
// Add JWT token to all requests
|
||||||
|
axios_jwt.interceptors.request.use(async function (config) {
|
||||||
|
const access_token = await get_access_token();
|
||||||
|
|
||||||
|
if (access_token) {
|
||||||
|
config.headers.Authorization = "Bearer " + access_token;
|
||||||
|
} else if (!router.currentRoute.value.fullPath.includes('login')) {
|
||||||
|
// Only redirect if we're not already on the login page
|
||||||
|
router.push({
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
next: router.currentRoute.value.fullPath,
|
||||||
|
error: 'Please log in to continue'
|
||||||
}
|
}
|
||||||
return config
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
}, function (error) {
|
}, function (error) {
|
||||||
// Do something with request error
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<caption>
|
|
||||||
<h2>Reading History</h2>
|
<h2>Reading History</h2>
|
||||||
</caption>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<table class="table table-striped table-bordered">
|
<table class="table table-striped table-bordered">
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<!-- Show these links only when user is authenticated -->
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-if="isAuthenticated">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
|
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
|
||||||
</li>
|
</li>
|
||||||
@@ -26,6 +27,12 @@
|
|||||||
<a class="nav-link" @click="logout">Log Out</a>
|
<a class="nav-link" @click="logout">Log Out</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<!-- Show login link when user is not authenticated -->
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-else>
|
||||||
|
<li class="nav-item">
|
||||||
|
<router-link :to="{name: 'login'}" class="nav-link">Log In</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -42,6 +49,11 @@ export default {
|
|||||||
visible: false
|
visible: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
isAuthenticated() {
|
||||||
|
return !!this.$store.state.jwt;
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
logout () {
|
logout () {
|
||||||
store.commit('logOut')
|
store.commit('logOut')
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container" ref="pdfContainer">
|
|
||||||
<div class="row w-100 pb-5 mb-5" v-if="loaded">
|
|
||||||
<pdf :src="pdfdata" :page="page" ref="pdfWindow" :resize="true">
|
|
||||||
<template v-slot:loading>
|
|
||||||
loading content here...
|
|
||||||
</template>
|
|
||||||
</pdf>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row navButtons pb-2">
|
|
||||||
<comic-paginate
|
|
||||||
v-model="page"
|
|
||||||
:page_count="numPages"
|
|
||||||
@setPage="setPage"
|
|
||||||
@prevComic="prevComic"
|
|
||||||
@nextComic="nextComic"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import pdfvuer from 'pdfvuer'
|
|
||||||
import api from "@/api";
|
|
||||||
import * as Hammer from 'hammerjs'
|
|
||||||
import ComicPaginate from "@/components/ComicPaginate";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "ThePdfReader",
|
|
||||||
components: {
|
|
||||||
ComicPaginate,
|
|
||||||
pdf: pdfvuer
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
page: 1,
|
|
||||||
numPages: 0,
|
|
||||||
pdfdata: undefined,
|
|
||||||
errors: [],
|
|
||||||
scale: 'page-width',
|
|
||||||
loaded: false,
|
|
||||||
key_timeout: null,
|
|
||||||
hammertime: null,
|
|
||||||
next_comic: {},
|
|
||||||
prev_comic: {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
selector: String
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
},
|
|
||||||
mounted () {
|
|
||||||
this.getPdf()
|
|
||||||
window.addEventListener('keyup', this.keyPressDebounce)
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
window.removeEventListener('keyup', this.keyPressDebounce)
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getPdf () {
|
|
||||||
let comic_data_url = '/api/read/' + this.selector + '/'
|
|
||||||
api.get(comic_data_url)
|
|
||||||
.then(response => {
|
|
||||||
let parameter = {
|
|
||||||
url: '/api/read/' + this.selector + '/pdf/',
|
|
||||||
httpHeaders: { Authorization: 'Bearer ' + this.$store.state.jwt.access },
|
|
||||||
withCredentials: true,
|
|
||||||
}
|
|
||||||
this.pdfdata = pdfvuer.createLoadingTask(parameter);
|
|
||||||
this.pdfdata.then(pdf => {
|
|
||||||
this.numPages = pdf.numPages;
|
|
||||||
this.loaded = true
|
|
||||||
this.page = response.data.last_read_page+1
|
|
||||||
this.setReadPage(this.page)
|
|
||||||
this.next_comic = response.data.next_comic
|
|
||||||
this.prev_comic = response.data.prev_comic
|
|
||||||
this.hammertime = new Hammer(this.$refs.pdfContainer, {})
|
|
||||||
this.hammertime.on('swipeleft', (_e, self=this) => {
|
|
||||||
self.nextPage()
|
|
||||||
})
|
|
||||||
this.hammertime.on('swiperight', (_e, self=this) => {
|
|
||||||
self.prevPage()
|
|
||||||
})
|
|
||||||
this.hammertime.on('tap', (_e, self=this) => {
|
|
||||||
self.nextPage()
|
|
||||||
})
|
|
||||||
}).catch(e => {console.log(e)});
|
|
||||||
})
|
|
||||||
|
|
||||||
},
|
|
||||||
prevComic(){
|
|
||||||
this.$router.push({
|
|
||||||
name: this.prev_comic.route,
|
|
||||||
params: {selector: this.prev_comic.selector}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
nextComic(){
|
|
||||||
this.$router.push({
|
|
||||||
name: this.next_comic.route,
|
|
||||||
params: {selector: this.next_comic.selector}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
nextPage () {
|
|
||||||
if (this.page < this.numPages){
|
|
||||||
this.page += 1
|
|
||||||
this.setReadPage(this.page)
|
|
||||||
} else {
|
|
||||||
this.nextComic()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
prevPage() {
|
|
||||||
if (this.page > 1){
|
|
||||||
this.page -= 1
|
|
||||||
this.setReadPage(this.page)
|
|
||||||
} else {
|
|
||||||
this.prevComic()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setPage(num) {
|
|
||||||
this.page = num
|
|
||||||
this.setReadPage(this.page)
|
|
||||||
},
|
|
||||||
setReadPage(num){
|
|
||||||
this.$refs.pdfContainer.scrollIntoView()
|
|
||||||
let payload = {
|
|
||||||
page: num-1
|
|
||||||
}
|
|
||||||
api.put('/api/read/'+ this.selector +'/set_page/', payload)
|
|
||||||
},
|
|
||||||
keyPressDebounce(e){
|
|
||||||
clearTimeout(this.key_timeout)
|
|
||||||
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
|
|
||||||
},
|
|
||||||
keyPress(e) {
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
this.nextPage()
|
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
this.prevPage()
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
window.scrollTo({
|
|
||||||
top: window.scrollY-window.innerHeight*.7,
|
|
||||||
left: 0,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
window.scrollTo({
|
|
||||||
top: window.scrollY+window.innerHeight*.7,
|
|
||||||
left: 0,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.navButtons {
|
|
||||||
position: fixed;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 1030;
|
|
||||||
width: auto;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<caption>
|
|
||||||
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
|
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
|
||||||
Mark selected issues as:
|
Mark selected issues as:
|
||||||
<select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
|
<select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
<option value="mark_read">Read</option>
|
<option value="mark_read">Read</option>
|
||||||
<option value="mark_unread">Un-Read</option>
|
<option value="mark_unread">Un-Read</option>
|
||||||
</select>
|
</select>
|
||||||
</caption>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<table class="table table-striped table-bordered">
|
<table class="table table-striped table-bordered">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import store from '@/store'
|
||||||
|
|
||||||
const ReadView = () => import('@/views/ReadView')
|
const ReadView = () => import('@/views/ReadView')
|
||||||
const RecentView = () => import('@/views/RecentView')
|
const RecentView = () => import('@/views/RecentView')
|
||||||
@@ -8,6 +9,30 @@ const UserView = () => import('@/views/UserView')
|
|||||||
const LoginView = () => import('@/views/LoginView')
|
const LoginView = () => import('@/views/LoginView')
|
||||||
const HistoryView = () => import('@/views/HistoryView')
|
const HistoryView = () => import('@/views/HistoryView')
|
||||||
|
|
||||||
|
// Navigation guard to check if user is authenticated
|
||||||
|
function requireAuth(to, from, next) {
|
||||||
|
if (!store.state.jwt) {
|
||||||
|
next({
|
||||||
|
name: 'login',
|
||||||
|
query: { next: to.fullPath, error: 'Please log in to access this page' }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation guard to check if user is admin
|
||||||
|
function requireAdmin(to, from, next) {
|
||||||
|
if (!store.state.jwt || !store.getters.is_superuser) {
|
||||||
|
next({
|
||||||
|
name: 'login',
|
||||||
|
query: { next: to.fullPath, error: 'Admin access required' }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -20,13 +45,15 @@ const routes = [
|
|||||||
path: '/browse/:selector?',
|
path: '/browse/:selector?',
|
||||||
name: 'browse',
|
name: 'browse',
|
||||||
component: BrowseView,
|
component: BrowseView,
|
||||||
props: true
|
props: true,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/read/:selector',
|
path: '/read/:selector',
|
||||||
name: 'read',
|
name: 'read',
|
||||||
component: ReadView,
|
component: ReadView,
|
||||||
props: true
|
props: true,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -36,23 +63,27 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/recent',
|
path: '/recent',
|
||||||
name: 'recent',
|
name: 'recent',
|
||||||
component: RecentView
|
component: RecentView,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/history',
|
path: '/history',
|
||||||
name: 'history',
|
name: 'history',
|
||||||
component: HistoryView
|
component: HistoryView,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/account',
|
path: '/account',
|
||||||
name: 'account',
|
name: 'account',
|
||||||
component: AccountView
|
component: AccountView,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/user/:userid?',
|
path: '/user/:userid?',
|
||||||
name: 'user',
|
name: 'user',
|
||||||
component: UserView,
|
component: UserView,
|
||||||
props: true
|
props: true,
|
||||||
|
beforeEnter: requireAdmin
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/about',
|
path: '/about',
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { createStore } from 'vuex'
|
import { createStore } from 'vuex'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import jwtDecode from 'jwt-decode'
|
import { jwtDecode } from "jwt-decode";
|
||||||
import {useToast} from "vue-toast-notification";
|
import {useToast} from "vue-toast-notification";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
|
|
||||||
|
// We'll no longer use localStorage for tokens
|
||||||
|
// Instead, tokens will be stored in httpOnly cookies by the backend
|
||||||
|
// and automatically included in requests
|
||||||
function get_jwt_from_storage(){
|
function get_jwt_from_storage(){
|
||||||
try {
|
return null; // Initial state will be null until login
|
||||||
return JSON.parse(localStorage.getItem('t'))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function get_user_from_storage(){
|
function get_user_from_storage(){
|
||||||
try {
|
try {
|
||||||
@@ -44,12 +43,18 @@ export default createStore({
|
|||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
updateToken(state, newToken){
|
updateToken(state, newToken){
|
||||||
localStorage.setItem('t', JSON.stringify(newToken));
|
// No longer storing tokens in localStorage
|
||||||
|
// Tokens are stored in httpOnly cookies by the backend
|
||||||
state.jwt = newToken;
|
state.jwt = newToken;
|
||||||
},
|
},
|
||||||
logOut(state){
|
logOut(state){
|
||||||
localStorage.removeItem('t');
|
// Clear user data from localStorage
|
||||||
localStorage.removeItem('u')
|
localStorage.removeItem('u')
|
||||||
|
// Clear state
|
||||||
|
|
||||||
|
// Make a request to the backend to invalidate the token
|
||||||
|
axios.post('/api/token/blacklist/', { refresh: state.jwt?.refresh })
|
||||||
|
.catch(error => console.error('Error blacklisting token:', error));
|
||||||
state.jwt = null;
|
state.jwt = null;
|
||||||
state.user = null
|
state.user = null
|
||||||
},
|
},
|
||||||
@@ -92,31 +97,66 @@ export default createStore({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
refreshToken(){
|
refreshToken(){
|
||||||
|
// Don't attempt to refresh if we don't have a token
|
||||||
|
if (!this.state.jwt || !this.state.jwt.refresh) {
|
||||||
|
return Promise.reject(new Error('No refresh token available'));
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
refresh: this.state.jwt.refresh
|
refresh: this.state.jwt.refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
return axios.post('/api/token/refresh/', payload)
|
return axios.post('/api/token/refresh/', payload)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
this.commit('updateToken', response.data)
|
this.commit('updateToken', response.data);
|
||||||
|
return response.data;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error)
|
console.error('Token refresh failed:', error);
|
||||||
// router.push({name: 'login', query: {area: 'store'}})
|
// If refresh fails, log the user out and redirect to login
|
||||||
})
|
this.commit('logOut');
|
||||||
|
router.push({
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
next: router.currentRoute.value.fullPath,
|
||||||
|
error: 'Your session has expired. Please log in again.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
inspectToken(){
|
inspectToken(){
|
||||||
const token = this.state.jwt;
|
const token = this.state.jwt;
|
||||||
if(token){
|
if (!token) return;
|
||||||
const decoded = jwtDecode(token);
|
|
||||||
const exp = decoded.exp
|
try {
|
||||||
const orig_iat = decoded.iat
|
// For access token
|
||||||
if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){
|
const decoded = jwtDecode(token.access);
|
||||||
this.dispatch('refreshToken')
|
const exp = decoded.exp;
|
||||||
} else if (exp -(Date.now()/1000) < 1800){
|
const now = Date.now() / 1000;
|
||||||
// DO NOTHING, DO NOT REFRESH
|
|
||||||
} else {
|
// Refresh when token is within 5 minutes of expiring
|
||||||
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
|
const refreshThreshold = 300; // 5 minutes in seconds
|
||||||
|
|
||||||
|
if (exp - now < refreshThreshold) {
|
||||||
|
// Token is about to expire, refresh it
|
||||||
|
this.dispatch('refreshToken');
|
||||||
|
} else if (exp < now) {
|
||||||
|
// Token is already expired, force logout
|
||||||
|
this.commit('logOut');
|
||||||
|
router.push({
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
next: router.currentRoute.value.fullPath,
|
||||||
|
error: 'Your session has expired. Please log in again.'
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error inspecting token:', error);
|
||||||
|
// If we can't decode the token, log the user out
|
||||||
|
this.commit('logOut');
|
||||||
|
router.push({name: 'login'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
<div class="row" v-if="!initialSetupRequired">
|
<div class="row" v-if="!initialSetupRequired">
|
||||||
<div class="col col-lg-4" />
|
<div class="col col-lg-4" />
|
||||||
<div class="col col-lg-4" id="login-col">
|
<div class="col col-lg-4" id="login-col">
|
||||||
|
<!-- Display error message if present -->
|
||||||
|
<div class="alert alert-danger" v-if="errorMessage">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form @submit="login" v-on:submit.prevent="onSubmit">
|
<form @submit="login" v-on:submit.prevent="onSubmit">
|
||||||
<label class="form-label" for="username">Username</label>
|
<label class="form-label" for="username">Username</label>
|
||||||
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
|
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
|
||||||
@@ -34,7 +39,8 @@ export default {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
password_alert: false,
|
password_alert: false,
|
||||||
initialSetupRequired: false
|
initialSetupRequired: false,
|
||||||
|
errorMessage: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -43,11 +49,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
// Check for error message in route query params
|
||||||
|
if (this.$route.query.error) {
|
||||||
|
this.errorMessage = this.$route.query.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if initial setup is required
|
||||||
axios.get('/api/initial_setup/required/').then(response => {
|
axios.get('/api/initial_setup/required/').then(response => {
|
||||||
if (response.data.required){
|
if (response.data.required){
|
||||||
this.initialSetupRequired = true
|
this.initialSetupRequired = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
// Clear error message when route changes
|
||||||
|
watch: {
|
||||||
|
'$route'(to) {
|
||||||
|
this.errorMessage = to.query.error || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<the-breadcrumbs :selector="selector" />
|
<the-breadcrumbs :selector="selector" />
|
||||||
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
|
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
|
||||||
<the-pdf-reader :selector="selector" v-if="pdf_loaded" :key="selector" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
||||||
import TheComicReader from "@/components/TheComicReader";
|
import TheComicReader from "@/components/TheComicReader";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import ThePdfReader from "@/components/ThePdfReader";
|
|
||||||
export default {
|
export default {
|
||||||
name: "ReadView",
|
name: "ReadView",
|
||||||
components: {ThePdfReader, TheComicReader, TheBreadcrumbs},
|
components: {TheComicReader, TheBreadcrumbs},
|
||||||
props: {
|
props: {
|
||||||
selector: String
|
selector: String
|
||||||
},
|
},
|
||||||
@@ -19,7 +17,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
comic_data: {},
|
comic_data: {},
|
||||||
comic_loaded: false,
|
comic_loaded: false,
|
||||||
pdf_loaded: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -27,13 +24,7 @@ export default {
|
|||||||
let comic_data_url = '/api/read/' + this.selector + '/type/'
|
let comic_data_url = '/api/read/' + this.selector + '/type/'
|
||||||
api.get(comic_data_url)
|
api.get(comic_data_url)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.data.type === 'pdf'){
|
|
||||||
this.pdf_loaded = true
|
|
||||||
this.comic_loaded = false
|
|
||||||
} else {
|
|
||||||
this.comic_loaded = true
|
this.comic_loaded = true
|
||||||
this.pdf_loaded = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch((error) => {console.log(error)})
|
.catch((error) => {console.log(error)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import UserEdit from "@/components/UserEdit";
|
|||||||
import alertMessages from "@/components/AlertMessages";
|
import alertMessages from "@/components/AlertMessages";
|
||||||
import AddUser from "@/components/AddUser";
|
import AddUser from "@/components/AddUser";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import store from "@/store";
|
|
||||||
|
|
||||||
const default_crumbs = [
|
const default_crumbs = [
|
||||||
{id: 0, selector: '', name: 'Home'},
|
{id: 0, selector: '', name: 'Home'},
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ module.exports = () => {
|
|||||||
plugins: [
|
plugins: [
|
||||||
new VueLoaderPlugin(),
|
new VueLoaderPlugin(),
|
||||||
new BundleTracker({
|
new BundleTracker({
|
||||||
filename: './webpack-stats.json',
|
filename: 'webpack-stats.json',
|
||||||
|
path: path.resolve(__dirname, './'),
|
||||||
publicPath: 'http://localhost:8080/'
|
publicPath: 'http://localhost:8080/'
|
||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const path = require('path')
|
|||||||
const { VueLoaderPlugin } = require('vue-loader')
|
const { VueLoaderPlugin } = require('vue-loader')
|
||||||
const BundleTracker = require('webpack-bundle-tracker');
|
const BundleTracker = require('webpack-bundle-tracker');
|
||||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
|
||||||
|
|
||||||
const webpack = require('webpack')
|
const webpack = require('webpack')
|
||||||
|
|
||||||
@@ -47,7 +46,8 @@ module.exports = (env = {}) => {
|
|||||||
plugins: [
|
plugins: [
|
||||||
new VueLoaderPlugin(),
|
new VueLoaderPlugin(),
|
||||||
new BundleTracker({
|
new BundleTracker({
|
||||||
filename: './webpack-stats.json',
|
filename: 'webpack-stats.json',
|
||||||
|
path: path.resolve(__dirname, './'),
|
||||||
publicPath: '/static/bundles/',
|
publicPath: '/static/bundles/',
|
||||||
integrity: true
|
integrity: true
|
||||||
}),
|
}),
|
||||||
|
|||||||
1931
poetry.lock
generated
1931
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,56 +1,46 @@
|
|||||||
[tool.black]
|
[project]
|
||||||
line_length = 119
|
|
||||||
|
|
||||||
[tool.poetry]
|
|
||||||
name = "cbwebreader"
|
name = "cbwebreader"
|
||||||
version = "1.1.7"
|
version = "1.1.3"
|
||||||
description = "CBR/Z Web Reader"
|
description = "CBR/Z Web Reader"
|
||||||
authors = ["ajurna <ajurna@gmail.com>"]
|
readme = "README.md"
|
||||||
license = "Creative Commons Attribution-ShareAlike 4.0 International License"
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"dj-database-url>=2.3.0",
|
||||||
|
"django>=5.1.7",
|
||||||
|
"django-boost>=2.1",
|
||||||
|
"django-bootstrap4>=25.1",
|
||||||
|
"django-cors-headers>=4.7.0",
|
||||||
|
"django-csp>=3.8",
|
||||||
|
"django-extensions>=3.2.3",
|
||||||
|
"django-filter>=25.1",
|
||||||
|
"django-imagekit>=5.0.0",
|
||||||
|
"django-permissions-policy>=4.25.0",
|
||||||
|
"django-silk>=5.3.2",
|
||||||
|
"django-sri>=0.8.0",
|
||||||
|
"django-webpack-loader>=3.1.1",
|
||||||
|
"djangorestframework>=3.16.0",
|
||||||
|
"djangorestframework-simplejwt>=5.5.0",
|
||||||
|
"drf-yasg>=1.21.10",
|
||||||
|
"flake8>=7.2.0",
|
||||||
|
"flake8-annotations>=3.1.1",
|
||||||
|
"gunicorn>=23.0.0",
|
||||||
|
"loguru>=0.7.3",
|
||||||
|
"mysqlclient>=2.2.7",
|
||||||
|
"pillow>=11.1.0",
|
||||||
|
"psycopg2>=2.9.10",
|
||||||
|
"pymupdf>=1.25.5",
|
||||||
|
"python-dotenv>=1.1.0",
|
||||||
|
"rarfile>=4.2",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[dependency-groups]
|
||||||
python = "^3.12"
|
dev = [
|
||||||
Django = "^4.1"
|
"coverage>=7.8.0",
|
||||||
gunicorn = "^21.2.0"
|
"ipython>=9.0.2",
|
||||||
dj-database-url = "^2.1.0"
|
"mypy>=1.15.0",
|
||||||
python-dotenv = "^1.0.0"
|
"pre-commit>=4.2.0",
|
||||||
loguru = "^0.7.0"
|
"pylint>=3.3.6",
|
||||||
django-silk = "^5.0.0"
|
"pylint-django>=2.6.1",
|
||||||
mysqlclient = "^2.0.1"
|
"pyopenssl>=25.0.0",
|
||||||
psycopg2-binary = "^2.9.6"
|
"werkzeug>=3.1.3",
|
||||||
rarfile = "^4.0"
|
]
|
||||||
django-extensions = "^3.2.1"
|
|
||||||
Pillow = "^10.0.1"
|
|
||||||
django-imagekit = "^5.0.0"
|
|
||||||
PyMuPDF = "~1.20.2"
|
|
||||||
django-bootstrap4 = "^23.1"
|
|
||||||
django-csp = "^3.7"
|
|
||||||
django-boost = "^2.1"
|
|
||||||
django-sri = "^0.7.0"
|
|
||||||
django-permissions-policy = "^4.15.0"
|
|
||||||
djangorestframework = "^3.13.1"
|
|
||||||
django-filter = "^23.1"
|
|
||||||
django-cors-headers = "^4.2.0"
|
|
||||||
djangorestframework-simplejwt = "^5.2.0"
|
|
||||||
django-webpack-loader = "^2.0.1"
|
|
||||||
drf-yasg = "^1.20.0"
|
|
||||||
drf-extensions = "^0.7.1"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
|
||||||
mypy = "^1.2.0"
|
|
||||||
Werkzeug = "^2.2"
|
|
||||||
pyOpenSSL = "^22.0.0"
|
|
||||||
ipython = "^8.12.0"
|
|
||||||
coverage = "^7.2.3"
|
|
||||||
pre-commit = "^3.2.2"
|
|
||||||
flake8 = "^6.0.0"
|
|
||||||
flake8-annotations = "^3.0.0"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
|
||||||
pylint = "^2.15.0"
|
|
||||||
pylint-django = "^2.5.3"
|
|
||||||
mypy = "^1.2.0"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core>=1.0.0"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
asgiref==3.7.2 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
autopep8==2.0.4 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
beautifulsoup4==4.12.2 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
|
|
||||||
dj-database-url==2.1.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-appconf==1.0.5 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-boost==2.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-bootstrap4==23.2 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-cors-headers==4.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-csp==3.7 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-extensions==3.2.3 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-filter==23.3 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-imagekit==5.0.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-permissions-policy==4.17.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-silk==5.0.4 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-sri==0.7.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django-webpack-loader==2.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
django==4.2.5 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
djangorestframework-simplejwt==5.3.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
djangorestframework==3.14.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
drf-extensions==0.7.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
drf-yasg==1.21.7 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
gprof2dot==2022.7.29 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
gunicorn==21.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
inflection==0.5.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
loguru==0.7.2 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
mysqlclient==2.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
packaging==23.2 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
pilkit==3.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
pillow==10.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
psycopg2-binary==2.9.9 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
pycodestyle==2.11.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
pyjwt==2.8.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
pymupdf==1.20.2 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
python-dotenv==1.0.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
pytz==2023.3.post1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
pyyaml==6.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
rarfile==4.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
soupsieve==2.5 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
sqlparse==0.4.4 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
typing-extensions==4.8.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
tzdata==2023.3 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
|
|
||||||
ua-parser==0.18.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
uritemplate==4.1.1 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
user-agents==2.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
|
||||||
win32-setctime==1.1.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
|
|
||||||
Reference in New Issue
Block a user