1 Commits

Author SHA1 Message Date
dependabot[bot]
7f788e98bb Bump pillow from 9.5.0 to 10.0.1
Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.5.0 to 10.0.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/9.5.0...10.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-04 01:59:29 +00:00
31 changed files with 6156 additions and 5650 deletions

View File

@@ -14,6 +14,5 @@
!package-lock.json
!package.json
!frontend
!uv.lock
/frontend/node_modules
/frontend/dist
/frontend/dist

View File

@@ -8,6 +8,12 @@ repos:
- id: end-of-file-fixer
- id: check-yaml
- 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
rev: "5.0.4"
hooks:

View File

@@ -1 +0,0 @@
3.13

View File

@@ -1,5 +1,4 @@
FROM python:3.13-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
FROM python:3.10-slim-bullseye
ENV PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
@@ -14,24 +13,19 @@ RUN mkdir /static
WORKDIR /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 \
&& apt install -y software-properties-common \
&& apt-add-repository non-free \
&& apt update \
&& apt install -y npm cron unrar libmariadb-dev libpq-dev pkg-config \
&& uv sync --frozen \
&& pip install --upgrade pip \
&& pip install -r requirements.txt \
&& cd frontend \
&& npm install \
&& npm run build \
&& apt remove -y npm software-properties-common pkg-config swig \
&& apt remove -y npm software-properties-common pkg-config \
&& rm -r node_modules \
&& apt -y auto-remove \
&& apt clean \

View File

@@ -1,3 +1,4 @@
$version=uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version
docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
docker push ajurna/cbwebreader --all-tags
poetry export --without-hashes -f requirements.txt --output requirements.txt
$version=poetry version -s
docker build . --no-cache -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
docker push ajurna/cbwebreader --all-tags

View File

@@ -46,7 +46,6 @@ INSTALLED_APPS = [
"corsheaders",
'django_filters',
'rest_framework',
'rest_framework_simplejwt.token_blacklist',
# 'silk'
]
@@ -192,14 +191,11 @@ BOOTSTRAP4 = {
},
}
CSP_DEFAULT_SRC = ("'none'",)
CSP_STYLE_SRC = (
"'self'",
"'unsafe-inline'"
)
CSP_STYLE_SRC = ("'self'", "'sha256-MBVp6JYxbC/wICelYC6eULCRpgi9kGezXXSaq/TS2+I='")
CSP_IMG_SRC = ("'self'", "data:")
CSP_FONT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "'unsafe-eval'", "'unsafe-inline'", "localhost:8080")
CSP_CONNECT_SRC = ("'self'", "ws://localhost:8080/ws")
CSP_SCRIPT_SRC = ("'self'",)
CSP_CONNECT_SRC = ("'self'",)
CSP_INCLUDE_NONCE_IN = ['script-src']
CSP_SCRIPT_SRC_ATTR = ("'self'",) # "'unsafe-inline'")
@@ -238,13 +234,8 @@ REST_FRAMEWORK = {
CORS_ALLOW_ALL_ORIGINS = True
SIMPLE_JWT = {
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'LEEWAY': timedelta(seconds=30),
'ALGORITHM': 'HS256',
'AUDIENCE': 'cbwebreader-users',
'ISSUER': 'cbwebreader',
'LEEWAY': timedelta(minutes=5),
}
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')

View File

@@ -22,9 +22,8 @@ from django.views.generic import TemplateView
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions
from rest_framework.routers import DefaultRouter
# from rest_framework_extensions.routers import ExtendedDefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView
from rest_framework_extensions.routers import ExtendedDefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from comic import rest, feeds
@@ -40,12 +39,12 @@ schema_view = get_schema_view(
permission_classes=[permissions.AllowAny]
)
router = DefaultRouter()
router = ExtendedDefaultRouter()
router.register(r'users', rest.UserViewSet)
router.register(r'browse', rest.BrowseViewSet, basename='browse')
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
router.register(r'read', rest.ReadViewSet, basename='read')
router.register(r'read/(?P<selector>[^/.]+)/image', rest.ImageViewSet, basename='image')
router.register(r'read', rest.ReadViewSet, basename='read')\
.register(r'image', rest.ImageViewSet, basename='image', parents_query_lookups=['selector'])
router.register(r'recent', rest.RecentComicsView, basename="recent")
router.register(r'history', rest.HistoryViewSet, basename='history')
router.register(r'action', rest.ActionViewSet, basename='action')
@@ -62,7 +61,6 @@ urlpatterns = [
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/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/token/blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'),
path('api/', include(router.urls)),
path("",
TemplateView.as_view(template_name="application.html"),

View File

@@ -7,7 +7,8 @@ import uuid
import django.db.models.deletion
from django.db import migrations, models
utc = datetime.timezone.utc
from django.utils.timezone import utc
class Migration(migrations.Migration):

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Optional, List, Union, Tuple, Final, IO
# noinspection PyPackageRequirements
import pymupdf
import fitz
import rarfile
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
@@ -52,8 +52,7 @@ class Directory(models.Model):
ordering = ['name']
def __str__(self) -> str:
return f"Directory: {self.name}: {self.parent}"
return f"Directory: {self.name}; {self.parent}"
@property
def title(self) -> str:
@@ -142,34 +141,21 @@ class ComicBook(models.Model):
return Path(base_dir, self.file_name)
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"
base_dir = settings.COMIC_BOOK_VOLUME
if self.directory:
archive_path = Path(base_dir, self.directory.path, self.file_name)
else:
base_dir = settings.COMIC_BOOK_VOLUME
if self.directory:
archive_path = Path(base_dir, self.directory.path, self.file_name)
else:
archive_path = Path(base_dir, self.file_name)
try:
archive = rarfile.RarFile(archive_path)
except rarfile.NotRarFile:
# pylint: disable=consider-using-with
archive = zipfile.ZipFile(archive_path)
except zipfile.BadZipfile:
return False, False
archive_path = Path(base_dir, self.file_name)
try:
archive = rarfile.RarFile(archive_path)
except rarfile.NotRarFile:
# pylint: disable=consider-using-with
archive = zipfile.ZipFile(archive_path)
except zipfile.BadZipfile:
return False, False
file_name, file_mime = self.get_archive_files(archive)[page]
return archive.open(file_name), file_mime
file_name, file_mime = self.get_archive_files(archive)[page]
return archive.open(file_name), file_mime
def generate_thumbnail_pdf(self, page_index: int = 0) -> Tuple[io.BytesIO, Image_type, str]:
img, pil_data = self._get_pdf_image(page_index if page_index else 0)
@@ -210,7 +196,8 @@ class ComicBook(models.Model):
self.save()
def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]:
doc = pymupdf.open(self.get_pdf())
# noinspection PyUnresolvedReferences
doc = fitz.open(self.get_pdf())
page = doc[page_index]
pix = page.get_pixmap()
mode: Final = "RGBA" if pix.alpha else "RGB"
@@ -252,7 +239,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.file_name)
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, pymupdf.Document], str]:
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, fitz.Document], str]:
archive_path = self.get_archive_path
try:
return rarfile.RarFile(archive_path), 'archive'
@@ -265,7 +252,7 @@ class ComicBook(models.Model):
try:
# noinspection PyUnresolvedReferences
return pymupdf.open(str(archive_path)), 'pdf'
return fitz.open(str(archive_path)), 'pdf'
except RuntimeError:
pass
raise NotCompatibleArchive
@@ -304,8 +291,8 @@ class ComicStatus(models.Model):
def __repr__(self) -> str:
return (
f"<ComicStatus: {self.user.username}: {self.comic.file_name}: {self.last_read_page}: "
f"{self.unread}: {self.finished}"
f"<ComicStatus:{self.user.username}:{self.comic.file_name}:{self.last_read_page}:"
f"{self.unread}:{self.finished}"
)

View File

@@ -1,4 +1,3 @@
from http.client import HTTPResponse
from pathlib import Path
from typing import Union, Optional, Dict, Iterable, List
from uuid import UUID
@@ -117,7 +116,7 @@ class BrowseViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector'
def get_queryset(self) -> None:
def get_queryset(self):
return
def list(self, request: Request) -> Response:
@@ -248,7 +247,7 @@ class ReadViewSet(viewsets.GenericViewSet):
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
@action(methods=['get'], detail=True)
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response, HTTPResponse]:
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response]:
book = models.ComicBook.objects.get(selector=selector)
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
try:
@@ -303,8 +302,8 @@ class ImageViewSet(viewsets.ViewSet):
renderer_classes = [PassthroughRenderer]
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"})
def retrieve(self, _request: Request, selector: UUID, page: int) -> FileResponse:
book = models.ComicBook.objects.get(selector=selector)
def retrieve(self, _request: Request, parent_lookup_selector: UUID, page: int) -> FileResponse:
book = models.ComicBook.objects.get(selector=parent_lookup_selector)
img, content = book.get_image(int(page) - 1)
self.renderer_classes[0].media_type = content
return FileResponse(img, content_type=content)

1
data

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ services:
expose:
- 8000
volumes:
- ${COMIC_BOOK_VOLUME}:/comics
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
# - c:/comics:/comics
- static_files:/static
- media_files:/media
@@ -35,7 +35,7 @@ services:
# command: /bin/bash entrypoint-cron.sh
database:
image: postgres:16-alpine
image: postgres:14-alpine
expose:
- 5432
volumes:
@@ -62,4 +62,4 @@ services:
- cbwebreader
volumes:
static_files:
media_files:
media_files:

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env sh
uv run manage.py migrate --settings=cbreader.settings.base
python manage.py migrate --settings=cbreader.settings.base
uv run manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
uv run gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"serve": "webpack-dev-server --config webpack.dev.js",
@@ -11,35 +11,38 @@
"@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/vue-fontawesome": "^3.0.1",
"axios": "^1.8.4",
"axios": "^0.27.2",
"bootstrap": "^5.2.0",
"hammerjs": "^2.0.8",
"jwt-decode": "^4.0.0",
"reveal.js": "^5.2.1",
"jwt-decode": "^3.1.2",
"pdfvuer": "^2.0.1",
"reveal.js": "^4.3.1",
"timeago.js": "^4.0.2",
"vue": "^3.5.13",
"vue": "^3.2.26",
"vue-router": "^4.0.3",
"vue-toast-notification": "^3.0",
"vue-toast-notification": "3.0",
"vuejs-paginate-next": "^1.0.2",
"vuex": "^4.0.0",
"webpack": "^5.98.0"
"webpack": "^5.76.0"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.0",
"@vue/cli-plugin-vuex": "^5.0.0",
"@vue/cli-service": "^5.0.8",
"eslint": "^9.24.0",
"eslint-plugin-vue": "^10.0.0",
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"jshint": "^2.13.5",
"mini-css-extract-plugin": "^2.9.2",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "^5.3.14",
"vue-loader": "^17.4.2",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-bundle-tracker": "^3.1.1",
"webpack-cli": "^6.0.1"
"mini-css-extract-plugin": "^2.6.1",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"vue-loader": "^17.0.0",
"webpack-bundle-analyzer": "^4.6.1",
"webpack-bundle-tracker": "^1.6.0",
"webpack-cli": "^4.10.0"
},
"eslintConfig": {
"root": true,

View File

@@ -1,79 +1,40 @@
import axios from "axios";
import router from "@/router";
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() {
// If we don't have tokens in the store, return null
if (!store.state.jwt || !store.state.jwt.access) {
return null;
}
try {
const access = jwtDecode(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;
}
let access = jwtDecode(store.state.jwt.access)
let refresh = jwtDecode(store.state.jwt.refresh)
if (access.exp - Date.now()/1000 < 5) {
if (refresh.exp - Date.now()/1000 < 5) {
await router.push({name: 'login'})
return null
} else {
return store.dispatch('refreshToken').then(() => {return store.state.jwt.access})
}
return store.state.jwt.access;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
return store.state.jwt.access
}
const axios_jwt = axios.create();
// Add CSRF token to all requests if using cookies for authentication
axios_jwt.interceptors.request.use(function(config) {
// Get CSRF token from cookie if it exists
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
if (csrfToken) {
config.headers['X-CSRFToken'] = csrfToken;
}
return config;
});
// Add JWT token to all requests
axios_jwt.interceptors.request.use(async function (config) {
const access_token = await get_access_token();
let access_token = await get_access_token().catch(() => {
if (router.currentRoute.value.fullPath.includes('login')){
router.push({name: 'login'})
}else {
router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }})
}
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'
}
});
})
config.headers = {
Authorization: "Bearer " + access_token
}
return config;
}, function (error) {
return config
}, function (error) {
// Do something with request error
return Promise.reject(error);
});
});
export default axios_jwt

View File

@@ -23,7 +23,9 @@
</div>
</div>
<div class="row">
<h2>Reading History</h2>
<caption>
<h2>Reading History</h2>
</caption>
</div>
<div class="row">
<table class="table table-striped table-bordered">

View File

@@ -6,8 +6,7 @@
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<!-- Show these links only when user is authenticated -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-if="isAuthenticated">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
</li>
@@ -27,12 +26,6 @@
<a class="nav-link" @click="logout">Log Out</a>
</li>
</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>
</nav>
@@ -49,11 +42,6 @@ export default {
visible: false
}
},
computed: {
isAuthenticated() {
return !!this.$store.state.jwt;
}
},
methods: {
logout () {
store.commit('logOut')

View File

@@ -0,0 +1,170 @@
<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>

View File

@@ -23,6 +23,7 @@
</div>
</div>
<div class="row">
<caption>
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
Mark selected issues as:
<select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
@@ -30,6 +31,7 @@
<option value="mark_read">Read</option>
<option value="mark_unread">Un-Read</option>
</select>
</caption>
</div>
<div class="row">
<table class="table table-striped table-bordered">

View File

@@ -1,5 +1,4 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import store from '@/store'
const ReadView = () => import('@/views/ReadView')
const RecentView = () => import('@/views/RecentView')
@@ -9,30 +8,6 @@ const UserView = () => import('@/views/UserView')
const LoginView = () => import('@/views/LoginView')
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 = [
{
path: '/',
@@ -45,15 +20,13 @@ const routes = [
path: '/browse/:selector?',
name: 'browse',
component: BrowseView,
props: true,
beforeEnter: requireAuth
props: true
},
{
path: '/read/:selector',
name: 'read',
component: ReadView,
props: true,
beforeEnter: requireAuth
props: true
},
{
path: '/login',
@@ -63,27 +36,23 @@ const routes = [
{
path: '/recent',
name: 'recent',
component: RecentView,
beforeEnter: requireAuth
component: RecentView
},
{
path: '/history',
name: 'history',
component: HistoryView,
beforeEnter: requireAuth
component: HistoryView
},
{
path: '/account',
name: 'account',
component: AccountView,
beforeEnter: requireAuth
component: AccountView
},
{
path: '/user/:userid?',
name: 'user',
component: UserView,
props: true,
beforeEnter: requireAdmin
props: true
},
{
path: '/about',

View File

@@ -1,15 +1,16 @@
import { createStore } from 'vuex'
import axios from 'axios'
import { jwtDecode } from "jwt-decode";
import jwtDecode from 'jwt-decode'
import {useToast} from "vue-toast-notification";
import router from "@/router";
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(){
return null; // Initial state will be null until login
try {
return JSON.parse(localStorage.getItem('t'))
} catch {
return null
}
}
function get_user_from_storage(){
try {
@@ -43,18 +44,12 @@ export default createStore({
},
mutations: {
updateToken(state, newToken){
// No longer storing tokens in localStorage
// Tokens are stored in httpOnly cookies by the backend
localStorage.setItem('t', JSON.stringify(newToken));
state.jwt = newToken;
},
logOut(state){
// Clear user data from localStorage
localStorage.removeItem('t');
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.user = null
},
@@ -97,66 +92,31 @@ export default createStore({
})
},
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 = {
refresh: this.state.jwt.refresh
}
return axios.post('/api/token/refresh/', payload)
.then((response) => {
this.commit('updateToken', response.data);
return response.data;
})
.catch((error) => {
console.error('Token refresh failed:', error);
// 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);
});
.then((response)=>{
this.commit('updateToken', response.data)
})
.catch((error)=>{
console.log(error)
// router.push({name: 'login', query: {area: 'store'}})
})
},
inspectToken(){
const token = this.state.jwt;
if (!token) return;
try {
// For access token
const decoded = jwtDecode(token.access);
const exp = decoded.exp;
const now = Date.now() / 1000;
// Refresh when token is within 5 minutes of expiring
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.'
}
});
if(token){
const decoded = jwtDecode(token);
const exp = decoded.exp
const orig_iat = decoded.iat
if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){
this.dispatch('refreshToken')
} else if (exp -(Date.now()/1000) < 1800){
// DO NOTHING, DO NOT REFRESH
} else {
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
}
} 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'});
}
}
},

View File

@@ -3,11 +3,6 @@
<div class="row" v-if="!initialSetupRequired">
<div class="col col-lg-4" />
<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">
<label class="form-label" for="username">Username</label>
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
@@ -39,8 +34,7 @@ export default {
username: '',
password: '',
password_alert: false,
initialSetupRequired: false,
errorMessage: ''
initialSetupRequired: false
}
},
methods: {
@@ -49,23 +43,11 @@ export default {
}
},
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 => {
if (response.data.required){
this.initialSetupRequired = true
}
})
},
// Clear error message when route changes
watch: {
'$route'(to) {
this.errorMessage = to.query.error || '';
}
}
}
</script>

View File

@@ -1,15 +1,17 @@
<template>
<the-breadcrumbs :selector="selector" />
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
<the-pdf-reader :selector="selector" v-if="pdf_loaded" :key="selector" />
</template>
<script>
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
import TheComicReader from "@/components/TheComicReader";
import api from "@/api";
import ThePdfReader from "@/components/ThePdfReader";
export default {
name: "ReadView",
components: {TheComicReader, TheBreadcrumbs},
components: {ThePdfReader, TheComicReader, TheBreadcrumbs},
props: {
selector: String
},
@@ -17,6 +19,7 @@ export default {
return {
comic_data: {},
comic_loaded: false,
pdf_loaded: false
}
},
methods: {
@@ -24,7 +27,13 @@ export default {
let comic_data_url = '/api/read/' + this.selector + '/type/'
api.get(comic_data_url)
.then(response => {
if (response.data.type === 'pdf'){
this.pdf_loaded = true
this.comic_loaded = false
} else {
this.comic_loaded = true
this.pdf_loaded = false
}
})
.catch((error) => {console.log(error)})
}
@@ -40,4 +49,4 @@ export default {
<style scoped>
</style>
</style>

View File

@@ -16,6 +16,7 @@ import UserEdit from "@/components/UserEdit";
import alertMessages from "@/components/AlertMessages";
import AddUser from "@/components/AddUser";
import router from "@/router";
import store from "@/store";
const default_crumbs = [
{id: 0, selector: '', name: 'Home'},

View File

@@ -45,8 +45,7 @@ module.exports = () => {
plugins: [
new VueLoaderPlugin(),
new BundleTracker({
filename: 'webpack-stats.json',
path: path.resolve(__dirname, './'),
filename: './webpack-stats.json',
publicPath: 'http://localhost:8080/'
}),
new webpack.DefinePlugin({
@@ -61,4 +60,4 @@ module.exports = () => {
hot: true,
}
};
}
}

View File

@@ -2,6 +2,7 @@ const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const webpack = require('webpack')
@@ -46,8 +47,7 @@ module.exports = (env = {}) => {
plugins: [
new VueLoaderPlugin(),
new BundleTracker({
filename: 'webpack-stats.json',
path: path.resolve(__dirname, './'),
filename: './webpack-stats.json',
publicPath: '/static/bundles/',
integrity: true
}),

1905
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,56 @@
[project]
name = "cbwebreader"
version = "1.1.3"
description = "CBR/Z Web Reader"
readme = "README.md"
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.black]
line_length = 119
[dependency-groups]
dev = [
"coverage>=7.8.0",
"ipython>=9.0.2",
"mypy>=1.15.0",
"pre-commit>=4.2.0",
"pylint>=3.3.6",
"pylint-django>=2.6.1",
"pyopenssl>=25.0.0",
"werkzeug>=3.1.3",
]
[tool.poetry]
name = "cbwebreader"
version = "1.1.6"
description = "CBR/Z Web Reader"
authors = ["ajurna <ajurna@gmail.com>"]
license = "Creative Commons Attribution-ShareAlike 4.0 International License"
[tool.poetry.dependencies]
python = "^3.10"
Django = "^4.1"
gunicorn = "^20.0.4"
dj-database-url = "^1.3.0"
python-dotenv = "^1.0.0"
loguru = "^0.7.0"
django-silk = "^5.0.0"
mysqlclient = "^2.0.1"
psycopg2 = "^2.9.6"
rarfile = "^4.0"
django-extensions = "^3.2.1"
Pillow = "^10.0.1"
django-imagekit = "^4.0.2"
PyMuPDF = "~1.20.2"
django-bootstrap4 = "^23.1"
django-csp = "^3.7"
django-boost = "^2.1"
django-sri = "^0.5.0"
django-permissions-policy = "^4.15.0"
djangorestframework = "^3.13.1"
django-filter = "^23.1"
django-cors-headers = "^3.14.0"
djangorestframework-simplejwt = "^5.2.0"
django-webpack-loader = "^1.6.0"
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"

49
requirements.txt Normal file
View File

@@ -0,0 +1,49 @@
asgiref==3.7.2 ; python_version >= "3.10" and python_version < "4.0"
autopep8==2.0.4 ; python_version >= "3.10" and python_version < "4.0"
beautifulsoup4==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
dj-database-url==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
django-appconf==1.0.5 ; python_version >= "3.10" and python_version < "4.0"
django-boost==2.1 ; python_version >= "3.10" and python_version < "4.0"
django-bootstrap4==23.2 ; python_version >= "3.10" and python_version < "4.0"
django-cors-headers==3.14.0 ; python_version >= "3.10" and python_version < "4.0"
django-csp==3.7 ; python_version >= "3.10" and python_version < "4.0"
django-extensions==3.2.3 ; python_version >= "3.10" and python_version < "4.0"
django-filter==23.2 ; python_version >= "3.10" and python_version < "4.0"
django-imagekit==4.1.0 ; python_version >= "3.10" and python_version < "4.0"
django-permissions-policy==4.17.0 ; python_version >= "3.10" and python_version < "4.0"
django-silk==5.0.3 ; python_version >= "3.10" and python_version < "4.0"
django-sri==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
django-webpack-loader==1.8.1 ; python_version >= "3.10" and python_version < "4.0"
django==4.2.4 ; python_version >= "3.10" and python_version < "4.0"
djangorestframework-simplejwt==5.3.0 ; python_version >= "3.10" and python_version < "4.0"
djangorestframework==3.14.0 ; python_version >= "3.10" and python_version < "4.0"
drf-extensions==0.7.1 ; python_version >= "3.10" and python_version < "4.0"
drf-yasg==1.21.7 ; python_version >= "3.10" and python_version < "4.0"
gprof2dot==2022.7.29 ; python_version >= "3.10" and python_version < "4.0"
gunicorn==20.1.0 ; python_version >= "3.10" and python_version < "4.0"
inflection==0.5.1 ; python_version >= "3.10" and python_version < "4.0"
loguru==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
mysqlclient==2.2.0 ; python_version >= "3.10" and python_version < "4.0"
packaging==23.1 ; python_version >= "3.10" and python_version < "4.0"
pilkit==2.0 ; python_version >= "3.10" and python_version < "4.0"
pillow==9.5.0 ; python_version >= "3.10" and python_version < "4.0"
psycopg2==2.9.7 ; python_version >= "3.10" and python_version < "4.0"
pycodestyle==2.11.0 ; python_version >= "3.10" and python_version < "4.0"
pyjwt==2.8.0 ; python_version >= "3.10" and python_version < "4.0"
pymupdf==1.20.2 ; python_version >= "3.10" and python_version < "4.0"
python-dotenv==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
pytz==2023.3 ; python_version >= "3.10" and python_version < "4.0"
pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
rarfile==4.0 ; python_version >= "3.10" and python_version < "4.0"
setuptools==68.1.2 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
soupsieve==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
sqlparse==0.4.4 ; python_version >= "3.10" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.10" and python_version < "3.11"
typing-extensions==4.7.1 ; python_version >= "3.10" and python_version < "4.0"
tzdata==2023.3 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
ua-parser==0.18.0 ; python_version >= "3.10" and python_version < "4.0"
uritemplate==4.1.1 ; python_version >= "3.10" and python_version < "4.0"
user-agents==2.2.0 ; python_version >= "3.10" and python_version < "4.0"
win32-setctime==1.1.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"

1193
uv.lock generated

File diff suppressed because it is too large Load Diff