Compare commits
13
Commits
15eb4016de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3681fd95eb | ||
|
|
12d2018ac5 | ||
|
|
0f15b7958f | ||
|
|
e62c50c616 | ||
|
|
c1421665c0 | ||
|
|
13e02faa8b | ||
|
|
2a0a79b19e | ||
|
|
069d059ef1 | ||
|
|
af27d47d58 | ||
|
|
3a20b28bb9 | ||
|
|
abfb183e7b | ||
|
|
45f08c8b9b | ||
|
|
723e50eb25 |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.venv
|
||||
.venv.bak
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
uploads
|
||||
converted
|
||||
bookish.db
|
||||
docs
|
||||
.env
|
||||
.sisyphus
|
||||
*.md
|
||||
!README.md
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
.git
|
||||
@@ -0,0 +1,27 @@
|
||||
BOOKISH_SECRET=cambia-questa-chiave-lunga-e-casuale
|
||||
PORT=5000
|
||||
BOOKISH_CLEANUP_HOURS=1
|
||||
# Gunicorn: solo se dietro reverse proxy che imposta X-Forwarded-For.
|
||||
# (lista separata da virgole, es. 127.0.0.1,172.17.0.1)
|
||||
# FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
|
||||
# AUTENTICAZIONE OPTIONAL (Basic) — attiva se entrambe valorizzate
|
||||
#BOOKISH_AUTH_USERNAME=admin
|
||||
#BOOKISH_AUTH_PASSWORD=cambia-me
|
||||
|
||||
# RATE LIMIT upload per IP/ora (0 = disabilitato, default 20)
|
||||
#BOOKISH_RATE_LIMIT_UPLOAD=20
|
||||
|
||||
# LOGGING STRUTTURATO (R7): "json" emette una riga JSON per log (grep/jq/loki)
|
||||
#LOG_FORMAT=json
|
||||
#LOG_LEVEL=info
|
||||
|
||||
# TIMEOUT CONVERSIONI in secondi (default 300). Alza per libri lunghi/PDF
|
||||
#BOOKISH_CONVERSION_TIMEOUT=600
|
||||
|
||||
# BUILD REPRODUCIBILE (R4b): sha256 di requirements.lock. Il default in
|
||||
# docker-compose.yml è già il pin corrente; in dev sovrascrivi con:
|
||||
# REQUIREMENTS_CHECKSUM="" → skippa il check in build locale
|
||||
# quando aggiorni le dipendenze (pip-compile) aggiorna anche il pin:
|
||||
# sed -i "s/[a-f0-9]\{64\}/$(sha256sum requirements.lock | awk '{print $1}')/" docker-compose.yml
|
||||
#REQUIREMENTS_CHECKSUM=d13e4fb14a909e0bd06d20894860152d5334aa341dd9dec2e5e4ca368a0d2a62
|
||||
@@ -0,0 +1,34 @@
|
||||
name: deps-checksum
|
||||
|
||||
# R4b: il lockfile è "pinnato" per costruzioni riproducibili. Questo job
|
||||
# fallisce se l'hash in docker-compose.yml non coincide con requirements.lock.
|
||||
# Quando aggiorni le dipendenze:
|
||||
# pip-compile --output-file=requirements.lock requirements.in
|
||||
# sed -i "s/d13e4fb14a909e0bd06d20894860152d5334aa341dd9dec2e5e4ca368a0d2a62/$(sha256sum requirements.lock | awk '{print $1}')/" docker-compose.yml
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checksum:
|
||||
runs-on: ubuntu-latest
|
||||
name: checksum lockfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Calcola hash attuale
|
||||
id: hash
|
||||
run: |
|
||||
H=$(sha256sum requirements.lock | awk '{print $1}')
|
||||
echo "hash=$H" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: hash pinned in docker-compose.yml
|
||||
run: |
|
||||
PIN=$(grep -oP 'REQUIREMENTS_CHECKSUM: \$\{REQUIREMENTS_CHECKSUM:-\K[a-f0-9]{64}' docker-compose.yml | head -1)
|
||||
[ -n "$PIN" ] || { echo "hash non pinnato in docker-compose.yml"; exit 1; }
|
||||
echo "pinned=$PIN"
|
||||
echo "atteso=${{ steps.hash.outputs.hash }}"
|
||||
[ "$PIN" = "${{ steps.hash.outputs.hash }}" ] || { echo "checksum non aggiornato — rivedi docker-compose.yml"; exit 1; }
|
||||
echo "checksum OK"
|
||||
@@ -0,0 +1,94 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: pytest (3.12)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install deps (lock)
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.lock pytest
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
.venv/bin/python -m pytest tests/ -v
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
name: Docker smoke test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare env
|
||||
run: |
|
||||
cp .env.example .env
|
||||
# secret di prova per il container (DEBUG=false in Docker)
|
||||
sed -i 's/^BOOKISH_SECRET=.*/BOOKISH_SECRET=ci-test-secret-long-enough/' .env
|
||||
|
||||
- name: Build image
|
||||
run: docker compose build
|
||||
|
||||
- name: Start stack
|
||||
run: docker compose up -d
|
||||
|
||||
- name: Wait for health
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:5000/healthz >/dev/null; then
|
||||
echo "healthz OK dopo ${i} tentativi"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "healthz non risponde"
|
||||
docker compose logs bookish
|
||||
exit 1
|
||||
|
||||
- name: "Smoke: upload epub -> epub (conversione, SSE)"
|
||||
run: |
|
||||
# finto epub (zip minimo vuoto accettato per estensione; la conversione
|
||||
# può fallire per formato, ma l'upload deve tornare 200 con task_id)
|
||||
printf 'PK\x03\x04 x' > /tmp/sample.epub
|
||||
RESP=$(curl -s -F "file=@/tmp/sample.epub" -F "output_format=epub" http://127.0.0.1:5000/upload)
|
||||
echo "$RESP"
|
||||
echo "$RESP" | grep -q '"task_id"' || { echo "task_id mancante"; exit 1; }
|
||||
|
||||
- name: "Smoke: CSP header presente"
|
||||
run: |
|
||||
curl -sI http://127.0.0.1:5000/ | grep -q "Content-Security-Policy" || { echo "CSP assente"; exit 1; }
|
||||
|
||||
- name: "Smoke: auth opzionale funziona (override)"
|
||||
run: |
|
||||
cat > /tmp/override.yml << 'EOF'
|
||||
services:
|
||||
bookish:
|
||||
environment:
|
||||
BOOKISH_AUTH_USERNAME: ci
|
||||
BOOKISH_AUTH_PASSWORD: ci-pass
|
||||
EOF
|
||||
docker compose -f docker-compose.yml -f /tmp/override.yml up -d
|
||||
sleep 6
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/)
|
||||
[ "$CODE" = "401" ] || { echo "atteso 401, ottenuto $CODE"; exit 1; }
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -u ci:ci-pass http://127.0.0.1:5000/)
|
||||
[ "$CODE" = "200" ] || { echo "atteso 200 con credenziali, ottenuto $CODE"; exit 1; }
|
||||
echo "auth OK"
|
||||
docker compose up -d # torna al default (senza auth)
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker compose down --remove-orphans --volumes
|
||||
@@ -5,3 +5,5 @@ uploads/
|
||||
converted/
|
||||
.venv.bak/
|
||||
.sisyphus/
|
||||
bookish.db
|
||||
.env
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
# Build riproducibile (R4b): se REQUIREMENTS_CHECKSUM è valorizzato, la
|
||||
# build fallisce se requirements.lock non coincide — nessuna dipendenza può
|
||||
# cambiare senza aggiornare il pin. Genera con:
|
||||
# sha256sum requirements.lock | awk '{print $1}'
|
||||
ARG REQUIREMENTS_CHECKSUM=""
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY requirements.txt requirements.lock ./
|
||||
RUN test -z "$REQUIREMENTS_CHECKSUM" \
|
||||
-o "$(sha256sum requirements.lock | awk '{print $1}')" = "$REQUIREMENTS_CHECKSUM" \
|
||||
|| { echo "FATAL: requirements.lock non corrisponde a REQUIREMENTS_CHECKSUM" >&2; exit 1; }
|
||||
RUN python -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.lock
|
||||
|
||||
FROM python:3.12-slim-bookworm AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pandoc \
|
||||
libcairo2 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi8 \
|
||||
shared-mime-info \
|
||||
fonts-dejavu-core \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --gid 1000 bookish \
|
||||
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/bookish bookish
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=bookish:bookish app.py config.py gunicorn.conf.py ./
|
||||
COPY --chown=bookish:bookish bookish ./bookish
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
BOOKISH_DATA_DIR=/data \
|
||||
BOOKISH_DEBUG=false \
|
||||
PORT=5000
|
||||
|
||||
RUN mkdir -p /data/uploads /data/converted \
|
||||
&& chown -R bookish:bookish /data /app
|
||||
|
||||
USER bookish
|
||||
EXPOSE 5000
|
||||
VOLUME ["/data"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=3)"
|
||||
|
||||
CMD ["gunicorn", "--config", "gunicorn.conf.py", "app:app"]
|
||||
@@ -1,123 +1,111 @@
|
||||
# 📚 Bookish
|
||||
# Bookish (fork Docker)
|
||||
|
||||
**Convertitore e reader di ebook self-hostato** — Flask, Pandoc, WeasyPrint.
|
||||
Convertitore e reader di ebook self-hostato — Flask, Pandoc, WeasyPrint, SQLite.
|
||||
|
||||
Carica un ebook in qualsiasi formato, convertilo in un altro, o leggilo direttamente nel browser con un reader completo.
|
||||
Questo repository è un fork di Bookish pensato per girare in Docker. L'originale resta in `bookish/`.
|
||||
|
||||
---
|
||||
|
||||
## Funzionalità
|
||||
|
||||
### 🔄 Convertitore
|
||||
| Formato | Input | Output |
|
||||
|---------|-------|--------|
|
||||
| EPUB | ✅ | ✅ |
|
||||
| PDF | ✅ | ✅ |
|
||||
| DOCX | ✅ | ✅ |
|
||||
| Markdown | ✅ | ✅ |
|
||||
| Testo (TXT) | ✅ | ✅ |
|
||||
| HTML | ✅ | ✅ |
|
||||
| FB2 | ✅ | ✅ |
|
||||
| MOBI | ✅ | ❌ |
|
||||
| LaTeX | ✅ | ✅ |
|
||||
|
||||
- Drag & drop upload
|
||||
- Barra progresso in tempo reale (SSE)
|
||||
- Download automatico del convertito
|
||||
|
||||
### 📖 Reader
|
||||
- **EPUB** via [epub.js](https://github.com/futurepress/epub.js)
|
||||
- **PDF** via [pdf.js](https://mozilla.github.io/pdf.js/)
|
||||
- **Testo/Markdown/DOCX/FB2/HTML** convertiti e renderizzati
|
||||
|
||||
Caratteristiche reader:
|
||||
- Zoom e regolazione font
|
||||
- Temi: chiaro, scuro, seppia
|
||||
- Ricerca full-text
|
||||
- Indice navigabile
|
||||
- Segnalibri e salvataggio posizione
|
||||
- Vista singola/doppia pagina
|
||||
- Navigazione tastiera
|
||||
- Fullscreen
|
||||
|
||||
### ⚙️ Motore di conversione
|
||||
- **Pandoc** come motore primario (decine di formati)
|
||||
- **WeasyPrint** per PDF con layout avanzato
|
||||
- **python-docx** per DOCX con stili
|
||||
- **BeautifulSoup** per post-processing HTML/tabelle
|
||||
|
||||
---
|
||||
|
||||
## Requisiti
|
||||
|
||||
- Python 3.10+
|
||||
- Pandoc (incluso via pypandoc-binary)
|
||||
- Librerie di sistema per WeasyPrint (libpango, libcairo)
|
||||
|
||||
## Installazione
|
||||
## Avvio con Docker
|
||||
|
||||
```bash
|
||||
git clone http://192.168.0.183:8085/truetype/bookish.git
|
||||
cd bookish
|
||||
cp .env.example .env
|
||||
# modifica BOOKISH_SECRET in .env
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Apri `http://localhost:5000`.
|
||||
|
||||
Dati persistenti (SQLite, upload, convertiti) stanno nel volume `bookish-data` montato su `/data`.
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
| Variabile | Default | Descrizione |
|
||||
|---|---|---|---|
|
||||
| `BOOKISH_SECRET` | (obbligatorio se `DEBUG=false`) | Secret Flask |
|
||||
| `BOOKISH_DEBUG` | `false` in Docker, `true` con `run.sh` | Debugger Werkzeug |
|
||||
| `BOOKISH_DATA_DIR` | directory del progetto / `/data` in Docker | Upload, convertiti, `bookish.db` |
|
||||
| `BOOKISH_CLEANUP_HOURS` | `1` | Ore prima di cancellare i file temporanei |
|
||||
| `PORT` | `5000` | Porta host (compose) / bind gunicorn |
|
||||
| `BOOKISH_AUTH_USERNAME` | (vuoto) | Se impostato insieme a `BOOKISH_AUTH_PASSWORD`, abilita l'autenticazione Basic su tutte le route (tranne healthz/static) |
|
||||
| `BOOKISH_AUTH_PASSWORD` | (vuoto) | Password dell'autenticazione Basic |
|
||||
| `BOOKISH_RATE_LIMIT_UPLOAD` | `20` | Massimo upload per IP/ora (`0` disabilita) |
|
||||
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | IP dei proxy fidati (Caddy/nginx) per rispettare `X-Forwarded-For` |
|
||||
|
||||
## Avvio locale (senza Docker)
|
||||
|
||||
Serve Python 3.12 e Pandoc di sistema.
|
||||
|
||||
```bash
|
||||
sudo apt install pandoc
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
.venv/bin/pip install -r requirements.lock # o requirements.txt in sviluppo
|
||||
./run.sh
|
||||
```
|
||||
|
||||
## Avvio
|
||||
In locale `BOOKISH_DEBUG` è `true` e il secret di default è accettato. Non esporre questa modalità in LAN.
|
||||
|
||||
## Dipendenze e lockfile
|
||||
|
||||
- `requirements.txt` — dipendenze dirette (per lo sviluppo).
|
||||
- `requirements.lock` — lock completo (dirette + transitive) generato con
|
||||
`pip-compile`, usato dalla build Docker per build riproducibili.
|
||||
|
||||
Per aggiornare il lock (in Python 3.12, come l'immagine):
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
# oppure su porta specifica:
|
||||
./run.sh 8080
|
||||
pip install pip-tools
|
||||
pip-compile --strip-extras --no-hash --output-file requirements.lock requirements.txt
|
||||
```
|
||||
|
||||
L'app cerca automaticamente una porta libera a partire da quella specificata.
|
||||
La build Docker verifica che `requirements.lock` coincida con l'hash pinnato
|
||||
in `docker-compose.yml` (`REQUIREMENTS_CHECKSUM`, R4b): se aggiorni le
|
||||
dipendenze, aggiorna anche il pin:
|
||||
|
||||
Apri il browser su `http://localhost:5000` (o la porta assegnata).
|
||||
|
||||
---
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
bookish/
|
||||
├── app.py # Entry point
|
||||
├── config.py # Configurazioni
|
||||
├── requirements.txt
|
||||
├── run.sh
|
||||
├── bookish/
|
||||
│ ├── __init__.py # Flask factory
|
||||
│ ├── routes/
|
||||
│ │ ├── convert.py # Upload, SSE progress, download
|
||||
│ │ └── reader.py # Reader view
|
||||
│ ├── services/
|
||||
│ │ ├── converter.py # Conversione Pandoc + pipeline
|
||||
│ │ ├── sse_manager.py # SSE progress manager
|
||||
│ │ └── formatter.py # PDF/HTML post-processing
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html # Layout base
|
||||
│ │ ├── index.html # Convertitore
|
||||
│ │ └── reader.html # Reader
|
||||
│ └── static/
|
||||
│ ├── css/style.css
|
||||
│ ├── js/main.js # Frontend convertitore
|
||||
│ ├── js/reader.js # Frontend reader
|
||||
│ └── logo.svg
|
||||
├── uploads/ # File caricati (temporanei)
|
||||
├── converted/ # File convertiti (temporanei)
|
||||
└── docs/ # Documentazione
|
||||
```bash
|
||||
sed -i "s/[a-f0-9]\{64\}/$(sha256sum requirements.lock | awk '{print $1}')/" docker-compose.yml
|
||||
```
|
||||
|
||||
Vedi [docs/architecture.md](docs/architecture.md) per dettagli.
|
||||
(trova la riga `REQUIREMENTS_CHECKSUM:` — primo hash del file). La CI
|
||||
(`.github/workflows/checksum.yml`) fallisce se il pin non coincide col lock.
|
||||
|
||||
---
|
||||
## Note sul container
|
||||
|
||||
## Deployment
|
||||
- Gunicorn, 1 worker + thread: i task SSE vivono in memoria del processo.
|
||||
- Utente non-root `bookish` (uid 1000).
|
||||
- Conversione PDF può richiedere fino a 5 minuti (`timeout` gunicorn 300s).
|
||||
- Nessuna autenticazione di default: adatto a LAN fidata dietro reverse proxy.
|
||||
|
||||
Adatto per Proxmox LXC, Docker o VM leggera. Vedi [docs/deploy.md](docs/deploy.md).
|
||||
### Sicurezza opzionale
|
||||
|
||||
---
|
||||
Per esporre Bookish anche solo in LAN, abilita l'autenticazione Basic e
|
||||
limita gli upload:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
BOOKISH_AUTH_USERNAME=admin
|
||||
BOOKISH_AUTH_PASSWORD=una-password-lunga
|
||||
BOOKISH_RATE_LIMIT_UPLOAD=20 # upload/ora per IP
|
||||
```
|
||||
|
||||
- L'**autenticazione Basic** è la più semplice ma **non** cifra la password:
|
||||
usala solo su HTTP **dietro un reverse proxy HTTPS** (Caddy/nginx).
|
||||
- Il **rate limit** è in-memory (per processo, single-worker): adatto a uso
|
||||
personale; per difese più robuste metti un limite a livello di proxy.
|
||||
- Vengono applicati header di sicurezza su ogni risposta: `Content-Security-Policy`
|
||||
(`default-src 'self'`, nessuna allowlist CDN), `X-Content-Type-Options: nosniff`,
|
||||
`X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: no-referrer`.
|
||||
- Tutti i JavaScript del reader (epub.js, pdf.js+worker, JSZip, DOMPurify) sono
|
||||
**self-hosted** in `static/vendor/` → funziona **anche offline**, senza CDN.
|
||||
- I contenuti HTML del reader vengono **sanitizzati lato client**
|
||||
(DOMPurify self-hosted in `static/vendor/`).
|
||||
|
||||
## API extra
|
||||
|
||||
`GET /healthz` — healthcheck Docker (`{"status":"ok"}`).
|
||||
|
||||
## Licenza
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import socket
|
||||
from bookish import create_app
|
||||
|
||||
@@ -11,7 +12,6 @@ def find_free_port(preferred=5000):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("127.0.0.1", port)) != 0:
|
||||
return port
|
||||
# fallback: lascia che il sistema assegni una porta
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
@@ -19,7 +19,11 @@ def find_free_port(preferred=5000):
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
preferred = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1].isdigit() else 5000
|
||||
from config import DEBUG
|
||||
|
||||
preferred = int(os.environ.get("PORT", "5000"))
|
||||
if len(sys.argv) > 1 and sys.argv[1].isdigit():
|
||||
preferred = int(sys.argv[1])
|
||||
port = find_free_port(preferred)
|
||||
print(f"📚 Bookish avviato su http://0.0.0.0:{port}")
|
||||
app.run(host="0.0.0.0", port=port, debug=True)
|
||||
print(f"Bookish avviato su http://0.0.0.0:{port} (debug={DEBUG})")
|
||||
app.run(host="0.0.0.0", port=port, debug=DEBUG)
|
||||
+46
-3
@@ -1,6 +1,13 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from config import SECRET_KEY, DEBUG, UPLOAD_FOLDER, CONVERTED_FOLDER
|
||||
import time
|
||||
from flask import Flask, jsonify, request
|
||||
from config import (
|
||||
SECRET_KEY,
|
||||
DEBUG,
|
||||
UPLOAD_FOLDER,
|
||||
CONVERTED_FOLDER,
|
||||
MAX_FILE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
def create_app():
|
||||
@@ -10,18 +17,42 @@ def create_app():
|
||||
static_url_path="/static")
|
||||
app.secret_key = SECRET_KEY
|
||||
app.debug = DEBUG
|
||||
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
|
||||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||||
app.config["CONVERTED_FOLDER"] = CONVERTED_FOLDER
|
||||
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(CONVERTED_FOLDER, exist_ok=True)
|
||||
|
||||
from bookish.services.observability import configure_logging, http_observe
|
||||
configure_logging(json_output=os.environ.get("LOG_FORMAT", "").lower() == "json")
|
||||
|
||||
# Hook di osservabilità: misura ogni richiesta (R7)
|
||||
@app.before_request
|
||||
def _obs_start():
|
||||
request._obs_start = time.perf_counter()
|
||||
|
||||
@app.after_request
|
||||
def _obs_record(response):
|
||||
start = getattr(request, "_obs_start", None)
|
||||
if start:
|
||||
http_observe(request.method, request.url_rule.rule if request.url_rule else None,
|
||||
response.status_code, time.perf_counter() - start)
|
||||
return response
|
||||
|
||||
from bookish.services.database import init_db
|
||||
init_db()
|
||||
|
||||
from bookish.routes.convert import convert_bp
|
||||
from bookish.routes.reader import reader_bp
|
||||
from bookish.routes.api import api_bp
|
||||
|
||||
app.register_blueprint(convert_bp)
|
||||
app.register_blueprint(reader_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
from bookish.services.security import register_security
|
||||
register_security(app)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
@@ -29,4 +60,16 @@ def create_app():
|
||||
from config import SUPPORTED_FORMATS
|
||||
return render_template("index.html", formats=SUPPORTED_FORMATS)
|
||||
|
||||
@app.route("/metrics")
|
||||
def metrics():
|
||||
from bookish.services.observability import render_metrics
|
||||
resp = app.response_class(render_metrics(), mimetype="text/plain; version=0.0.4; charset=utf-8")
|
||||
return resp
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
if not os.path.isdir(UPLOAD_FOLDER) or not os.access(UPLOAD_FOLDER, os.W_OK):
|
||||
return jsonify({"status": "error", "reason": "data dir not writable"}), 503
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,57 @@
|
||||
import time
|
||||
from flask import Blueprint, jsonify, request, current_app
|
||||
from bookish.services.database import (
|
||||
save_bookmark,
|
||||
get_bookmarks,
|
||||
delete_bookmark,
|
||||
save_position,
|
||||
get_position,
|
||||
)
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
# --- Bookmarks ---
|
||||
|
||||
@api_bp.route("/bookmarks/<content_hash>", methods=["GET"])
|
||||
def api_get_bookmarks(content_hash):
|
||||
bookmarks = get_bookmarks(content_hash)
|
||||
return jsonify({"bookmarks": bookmarks})
|
||||
|
||||
|
||||
@api_bp.route("/bookmarks", methods=["POST"])
|
||||
def api_add_bookmark():
|
||||
data = request.get_json(silent=True)
|
||||
if not data or not data.get("content_hash") or not data.get("id"):
|
||||
return jsonify({"error": "Richiesta non valida"}), 400
|
||||
|
||||
data["created"] = data.get("created", time.time())
|
||||
save_bookmark(data)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@api_bp.route("/bookmarks/<bookmark_id>", methods=["DELETE"])
|
||||
def api_delete_bookmark(bookmark_id):
|
||||
delete_bookmark(bookmark_id)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# --- Reading positions ---
|
||||
|
||||
@api_bp.route("/position/<content_hash>", methods=["GET"])
|
||||
def api_get_position(content_hash):
|
||||
pos = get_position(content_hash)
|
||||
if pos:
|
||||
return jsonify({"position": pos})
|
||||
return jsonify({"position": None})
|
||||
|
||||
|
||||
@api_bp.route("/position/<content_hash>", methods=["PUT"])
|
||||
def api_save_position(content_hash):
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Richiesta non valida"}), 400
|
||||
|
||||
data["type"] = data.get("type", "unknown")
|
||||
save_position(content_hash, data)
|
||||
return jsonify({"ok": True})
|
||||
+28
-13
@@ -1,15 +1,15 @@
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
from flask import Blueprint, request, jsonify, Response, send_file, render_template, current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
from config import SUPPORTED_FORMATS, OUTPUT_FORMATS_BY_INPUT
|
||||
from bookish.services.sse_manager import (
|
||||
create_task, generate_sse_events, update_progress,
|
||||
mark_error, get_status, cleanup_task
|
||||
)
|
||||
from config import SUPPORTED_FORMATS, OUTPUT_FORMATS_BY_INPUT, CLEANUP_HOURS
|
||||
from bookish.services.sse_manager import create_task, generate_sse_events, get_status
|
||||
from bookish.services.converter import run_conversion, get_format_from_ext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
convert_bp = Blueprint("convert", __name__)
|
||||
|
||||
ALLOWED_EXTENSIONS = set(SUPPORTED_FORMATS.keys())
|
||||
@@ -24,7 +24,7 @@ def _get_output_ext(output_format):
|
||||
return f".{output_format}"
|
||||
|
||||
|
||||
def _clean_old_files(folder, hours=1):
|
||||
def _clean_old_files(folder, hours=CLEANUP_HOURS):
|
||||
import time
|
||||
now = time.time()
|
||||
for f in os.listdir(folder):
|
||||
@@ -50,15 +50,11 @@ def upload():
|
||||
supported = ", ".join(sorted(SUPPORTED_FORMATS.keys()))
|
||||
return jsonify({"error": f"Formato '{ext}' non supportato. Supportati: {supported}"}), 400
|
||||
|
||||
_clean_old_files(current_app.config["UPLOAD_FOLDER"])
|
||||
_clean_old_files(current_app.config["CONVERTED_FOLDER"])
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
input_ext = get_format_from_ext(filename)
|
||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
input_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name)
|
||||
file.save(input_path)
|
||||
if not filename:
|
||||
return jsonify({"error": "Nome file non valido"}), 400
|
||||
|
||||
input_ext = get_format_from_ext(filename)
|
||||
output_format = request.form.get("output_format", "").lower()
|
||||
if output_format not in SUPPORTED_FORMATS:
|
||||
return jsonify({"error": f"Formato output '{output_format}' non supportato"}), 400
|
||||
@@ -69,6 +65,19 @@ def upload():
|
||||
"error": f"Conversione da {input_ext} a {output_format} non supportata"
|
||||
}), 400
|
||||
|
||||
_clean_old_files(current_app.config["UPLOAD_FOLDER"])
|
||||
_clean_old_files(current_app.config["CONVERTED_FOLDER"])
|
||||
|
||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
input_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name)
|
||||
file.save(input_path)
|
||||
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(input_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
sha256_hash.update(chunk)
|
||||
content_hash = sha256_hash.hexdigest()
|
||||
|
||||
output_ext = _get_output_ext(output_format)
|
||||
output_name = f"{uuid.uuid4().hex}{output_ext}"
|
||||
output_path = os.path.join(current_app.config["CONVERTED_FOLDER"], output_name)
|
||||
@@ -82,11 +91,16 @@ def upload():
|
||||
)
|
||||
thread.start()
|
||||
|
||||
logger.info("Conversione avviata: %s -> %s (task=%s)", input_ext, output_format, task_id,
|
||||
extra={"task_id": task_id, "input": input_ext, "output": output_format})
|
||||
|
||||
return jsonify({
|
||||
"task_id": task_id,
|
||||
"input_format": input_ext,
|
||||
"output_format": output_format,
|
||||
"filename": filename,
|
||||
"stored_filename": unique_name,
|
||||
"content_hash": content_hash,
|
||||
})
|
||||
|
||||
|
||||
@@ -115,6 +129,7 @@ def download(filename):
|
||||
return jsonify({"error": "File non trovato o già rimosso"}), 404
|
||||
|
||||
ext = os.path.splitext(safe)[1].lower().lstrip(".")
|
||||
logger.info("Download: %s.%s", safe, ext)
|
||||
mime = SUPPORTED_FORMATS.get(ext, {}).get("mime", "application/octet-stream")
|
||||
download_name = f"converted.{ext}"
|
||||
|
||||
|
||||
+44
-14
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from flask import Blueprint, render_template, jsonify, send_file, current_app
|
||||
import hashlib
|
||||
from flask import Blueprint, render_template, jsonify, send_file, current_app, url_for
|
||||
from config import SUPPORTED_FORMATS
|
||||
|
||||
reader_bp = Blueprint("reader", __name__)
|
||||
@@ -7,34 +8,63 @@ reader_bp = Blueprint("reader", __name__)
|
||||
READABLE_FORMATS = {"epub", "pdf", "html", "md", "txt", "fb2", "docx"}
|
||||
|
||||
|
||||
def _pdf_worker_url():
|
||||
return url_for("static", filename="vendor/pdf.worker.min.js")
|
||||
|
||||
|
||||
@reader_bp.route("/reader")
|
||||
def reader():
|
||||
return render_template("reader.html")
|
||||
return render_template("reader.html", pdf_worker_url=_pdf_worker_url())
|
||||
|
||||
|
||||
def _file_hash(filepath):
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _resolve_file(filename):
|
||||
"""Trova il file su disco gestendo sia il nome di upload (<uuid>_<name>)
|
||||
sia il nome convertito (<uuid>.ext). Ritorna (path, ext) o (None, None)."""
|
||||
safe = os.path.basename(filename)
|
||||
candidates = []
|
||||
for folder in (current_app.config["UPLOAD_FOLDER"], current_app.config["CONVERTED_FOLDER"]):
|
||||
candidates.append(os.path.join(folder, safe))
|
||||
# Fallback: un file 'libro_test.epub' passato a mano potrebbe essere
|
||||
# stato salvato come <uuid>_libro_test.epub (upload) → cerchiamo per
|
||||
# suffisso dentro i folder.
|
||||
if os.path.isdir(folder):
|
||||
for f in os.listdir(folder):
|
||||
if f == safe:
|
||||
continue
|
||||
if f.endswith("_" + safe):
|
||||
candidates.append(os.path.join(folder, f))
|
||||
for p in candidates:
|
||||
if os.path.isfile(p):
|
||||
ext = os.path.splitext(p)[1].lower().lstrip(".")
|
||||
return p, ext
|
||||
return None, None
|
||||
|
||||
|
||||
@reader_bp.route("/reader/<filename>")
|
||||
def reader_file(filename):
|
||||
safe = os.path.basename(filename)
|
||||
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
filepath = os.path.join(current_app.config["CONVERTED_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
filepath, ext = _resolve_file(filename)
|
||||
if not filepath:
|
||||
return jsonify({"error": "File non trovato"}), 404
|
||||
|
||||
ext = os.path.splitext(safe)[1].lower().lstrip(".")
|
||||
if ext not in READABLE_FORMATS:
|
||||
return jsonify({"error": f"Formato '{ext}' non supportato dal reader"}), 400
|
||||
|
||||
return render_template("reader.html", filename=safe, file_ext=ext)
|
||||
content_hash = _file_hash(filepath)
|
||||
return render_template("reader.html", filename=os.path.basename(filepath), file_ext=ext, content_hash=content_hash, pdf_worker_url=_pdf_worker_url())
|
||||
|
||||
|
||||
@reader_bp.route("/reader/serve/<filename>")
|
||||
def reader_serve(filename):
|
||||
safe = os.path.basename(filename)
|
||||
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
filepath = os.path.join(current_app.config["CONVERTED_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
filepath, ext = _resolve_file(filename)
|
||||
if not filepath:
|
||||
return jsonify({"error": "File non trovato"}), 404
|
||||
|
||||
return send_file(filepath, mimetype="application/octet-stream")
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
import pypandoc
|
||||
from config import SUPPORTED_FORMATS
|
||||
from bookish.services.sse_manager import update_progress, mark_complete, mark_error
|
||||
from bookish.services.observability import conversion_observe, track_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_format_from_ext(filename):
|
||||
@@ -18,33 +23,33 @@ def run_conversion(input_path, output_path, input_format, output_format, task_id
|
||||
Esegue la conversione con Pandoc, aggiornando il progresso via SSE.
|
||||
Per alcuni formati speciali delega a formatter.py.
|
||||
"""
|
||||
track_task(1)
|
||||
started = time.monotonic()
|
||||
outcome = "completed"
|
||||
try:
|
||||
update_progress(task_id, 10, "analyzing")
|
||||
_validate_pandoc_format(input_format, output_format)
|
||||
|
||||
update_progress(task_id, 30, "converting")
|
||||
|
||||
# Formati speciali che richiedono pipeline custom
|
||||
if input_format == "docx" and output_format == "pdf":
|
||||
_convert_docx_to_pdf(input_path, output_path, task_id)
|
||||
elif output_format == "pdf":
|
||||
_convert_to_pdf(input_path, output_path, input_format, task_id)
|
||||
elif input_format == "docx" and output_format in ("epub", "md", "html"):
|
||||
_convert_with_pandoc(input_path, output_path, input_format, output_format, task_id)
|
||||
elif input_format == "pdf" and output_format in ("epub", "docx", "md", "txt", "html"):
|
||||
_convert_with_pandoc(input_path, output_path, input_format, output_format, task_id)
|
||||
else:
|
||||
_convert_with_pandoc(input_path, output_path, input_format, output_format, task_id)
|
||||
|
||||
update_progress(task_id, 90, "finalizing")
|
||||
_post_process(output_path, output_format)
|
||||
|
||||
update_progress(task_id, 100)
|
||||
mark_complete(task_id, os.path.basename(output_path))
|
||||
|
||||
except Exception as e:
|
||||
outcome = "failed"
|
||||
logger.exception("Conversione fallita: %s -> %s (%s)", input_format, output_format, filename)
|
||||
mark_error(task_id, str(e))
|
||||
raise
|
||||
finally:
|
||||
conversion_observe(outcome, time.monotonic() - started)
|
||||
track_task(-1)
|
||||
|
||||
|
||||
def _validate_pandoc_format(input_format, output_format):
|
||||
@@ -86,8 +91,6 @@ def _convert_with_pandoc(input_path, output_path, input_format, output_format, t
|
||||
extra_args += ["--metadata", "title=Converted Document"]
|
||||
if output_format == "html":
|
||||
extra_args += ["--self-contained", "--mathjax"]
|
||||
if input_format == "pdf":
|
||||
extra_args += ["--pdf-engine=pdflatex"]
|
||||
|
||||
pandoc_input = _to_pandoc_format(input_format)
|
||||
pandoc_output = _to_pandoc_format(output_format)
|
||||
@@ -150,21 +153,4 @@ def _convert_docx_to_pdf(input_path, output_path, task_id):
|
||||
os.remove(html_path)
|
||||
|
||||
|
||||
def _post_process(output_path, output_format):
|
||||
"""Post-processing opzionale sul file generato."""
|
||||
if output_format == "epub":
|
||||
_fix_epub_metadata(output_path)
|
||||
|
||||
|
||||
def _fix_epub_metadata(epub_path):
|
||||
"""Aggiunge metadati minimi all'epub se mancanti."""
|
||||
try:
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
with zipfile.ZipFile(epub_path, "r") as z:
|
||||
content = z.read("META-INF/container.xml")
|
||||
if b"<dc:title>" not in content:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,119 @@
|
||||
import sqlite3
|
||||
import time
|
||||
from config import DB_PATH
|
||||
|
||||
|
||||
def _get_db():
|
||||
db = sqlite3.connect(DB_PATH, timeout=30)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
return db
|
||||
|
||||
|
||||
def init_db():
|
||||
db = _get_db()
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
cfi TEXT,
|
||||
href TEXT,
|
||||
page INTEGER,
|
||||
total_pages INTEGER,
|
||||
scroll_pct INTEGER,
|
||||
label TEXT NOT NULL,
|
||||
chapter_title TEXT,
|
||||
created REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookmarks_hash ON bookmarks(content_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reading_positions (
|
||||
content_hash TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
cfi TEXT,
|
||||
href TEXT,
|
||||
page INTEGER,
|
||||
scroll_pct INTEGER,
|
||||
chapter_title TEXT,
|
||||
updated REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
# --- Bookmarks ---
|
||||
|
||||
def save_bookmark(data):
|
||||
db = _get_db()
|
||||
db.execute(
|
||||
"""INSERT OR REPLACE INTO bookmarks
|
||||
(id, content_hash, type, cfi, href, page, total_pages, scroll_pct, label, chapter_title, created)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data["id"],
|
||||
data["content_hash"],
|
||||
data["type"],
|
||||
data.get("cfi"),
|
||||
data.get("href"),
|
||||
data.get("page"),
|
||||
data.get("total_pages"),
|
||||
data.get("scroll_pct"),
|
||||
data["label"],
|
||||
data.get("chapter_title"),
|
||||
data.get("created", time.time()),
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def get_bookmarks(content_hash):
|
||||
db = _get_db()
|
||||
rows = db.execute(
|
||||
"SELECT * FROM bookmarks WHERE content_hash = ? ORDER BY created ASC",
|
||||
(content_hash,),
|
||||
).fetchall()
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_bookmark(bookmark_id):
|
||||
db = _get_db()
|
||||
db.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
# --- Reading positions ---
|
||||
|
||||
def save_position(content_hash, data):
|
||||
db = _get_db()
|
||||
db.execute(
|
||||
"""INSERT OR REPLACE INTO reading_positions
|
||||
(content_hash, type, cfi, href, page, scroll_pct, chapter_title, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
content_hash,
|
||||
data["type"],
|
||||
data.get("cfi"),
|
||||
data.get("href"),
|
||||
data.get("page"),
|
||||
data.get("scroll_pct"),
|
||||
data.get("chapter_title"),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def get_position(content_hash):
|
||||
db = _get_db()
|
||||
row = db.execute(
|
||||
"SELECT * FROM reading_positions WHERE content_hash = ?", (content_hash,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Observability leggera senza dipendenze esterne (R7):
|
||||
|
||||
- Contatori Prometheus-style per /metrics (text exposition format).
|
||||
- Formatter di logging JSON opzionale (LOG_FORMAT=json).
|
||||
- Watchdog per conversioni lunghe (R5) quando attivo.
|
||||
|
||||
Niente export di terze parti: i contatori sono variabili di processo
|
||||
(single-worker, coerente con il design SSE in-memory).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics (Prometheus text exposition)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_metrics_lock = threading.Lock()
|
||||
|
||||
# http: (method, route) -> {"count": int, "sum": float, "count_by_status": {code: int}}
|
||||
_http = defaultdict(lambda: {"count": 0, "sum": 0.0, "by_status": defaultdict(int)})
|
||||
|
||||
# conversioni: result -> {"count": int, "sum_seconds": float}
|
||||
_conversions = defaultdict(lambda: {"count": 0, "sum_seconds": 0.0})
|
||||
|
||||
_started_at = time.time()
|
||||
_active_tasks = 0
|
||||
|
||||
|
||||
def http_observe(method, route, status_code, seconds):
|
||||
"""Registra una richiesta HTTP."""
|
||||
if route is None:
|
||||
route = "<unknown>"
|
||||
with _metrics_lock:
|
||||
m = _http[(method, route)]
|
||||
m["count"] += 1
|
||||
m["sum"] += seconds
|
||||
m["by_status"][status_code] += 1
|
||||
|
||||
|
||||
def conversion_observe(outcome, seconds):
|
||||
"""outcome: 'completed' | 'failed' | 'timeout'"""
|
||||
with _metrics_lock:
|
||||
m = _conversions[outcome]
|
||||
m["count"] += 1
|
||||
m["sum_seconds"] += seconds
|
||||
|
||||
|
||||
def track_task(delta):
|
||||
"""Incrementa/decrementa il gauge dei task attivi."""
|
||||
global _active_tasks
|
||||
with _metrics_lock:
|
||||
_active_tasks = max(0, _active_tasks + delta)
|
||||
|
||||
|
||||
def render_metrics():
|
||||
"""Endpoint body: testo Prometheus."""
|
||||
out = []
|
||||
out.append("# HELP bookish_http_requests_total Richieste HTTP totali per metodo e route.")
|
||||
out.append("# TYPE bookish_http_requests_total counter")
|
||||
with _metrics_lock:
|
||||
for (method, route), m in sorted(_http.items()):
|
||||
out.append(f'bookish_http_requests_total{{method="{method}",route="{route}"}} {m["count"]}')
|
||||
for code, c in sorted(m["by_status"].items()):
|
||||
out.append(f'bookish_http_requests_status{{method="{method}",route="{route}",status="{code}"}} {c}')
|
||||
out.append(f'bookish_http_request_duration_seconds_sum {sum(m["sum"] for m in _http.values()):.6f}')
|
||||
out.append(f'bookish_http_request_duration_seconds_count {sum(m["count"] for m in _http.values())}')
|
||||
for outcome, m in sorted(_conversions.items()):
|
||||
out.append(f'bookish_conversions_total{{result="{outcome}"}} {m["count"]}')
|
||||
out.append(f'bookish_conversion_duration_seconds_sum{{result="{outcome}"}} {m["sum_seconds"]:.6f}')
|
||||
out.append(f'bookish_conversion_duration_seconds_count{{result="{outcome}"}} {m["count"]}')
|
||||
out.append(f"bookish_active_tasks {_active_tasks}")
|
||||
out.append(f"bookish_uptime_seconds {time.time() - _started_at:.0f}")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured logging (LOG_FORMAT=json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""JSON a una riga per riga di log (logstash/graylog friendly)."""
|
||||
|
||||
def format(self, record):
|
||||
payload = {
|
||||
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info and record.exc_info[0]:
|
||||
payload["exc_info"] = self.formatException(record.exc_info)
|
||||
# extra fields (es. task_id) passano via record.__dict__
|
||||
for k, v in getattr(record, "__dict__", {}).items():
|
||||
if k not in payload and not k.startswith("_"):
|
||||
payload[k] = v
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def configure_logging(json_output=None):
|
||||
"""Configura il root logger: formato JSON se json_output=True."""
|
||||
if json_output is None:
|
||||
json_output = False
|
||||
root = logging.getLogger()
|
||||
# evita duplicati su reload
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
if json_output:
|
||||
handler.setFormatter(JsonFormatter())
|
||||
else:
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
root.addHandler(handler)
|
||||
root.setLevel(logging.INFO)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Middleware di sicurezza applicativi:
|
||||
- AUTH Basic opzionale per tutte le route (eccetto healthz/static)
|
||||
- Rate limit in-memory su /upload (anti disk-DoS: 1 IP = 20 upload/ora)
|
||||
- Header CSP restrittivi per mitigare XSS nel reader (contenuti iniettati)
|
||||
"""
|
||||
|
||||
import time
|
||||
from threading import Lock
|
||||
import hmac
|
||||
from importlib import import_module
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
|
||||
def _cfg(name, default=None):
|
||||
"""Legge dal modulo config a runtime: così il reload di config nelle
|
||||
fixture/test non richiede re-import di questo modulo."""
|
||||
return getattr(import_module("config"), name, default)
|
||||
|
||||
|
||||
# --- Rate limiting (in-memory, ok per single-user) ---
|
||||
|
||||
_rate = {} # ip -> list[timestamp]
|
||||
_rate_lock = Lock()
|
||||
|
||||
RATE_WINDOW_SECONDS = 3600 # 1 ora
|
||||
|
||||
|
||||
# --- Rate limiting (in-memory, ok per single-user) ---
|
||||
|
||||
_rate = {} # ip -> list[timestamp]
|
||||
_rate_lock = Lock()
|
||||
|
||||
RATE_WINDOW_SECONDS = 3600 # 1 ora
|
||||
|
||||
|
||||
def _rate_limited_ip(ip, limit=0):
|
||||
"""True se l'IP ha superato `limit` richieste nell'ultima ora (sliding)."""
|
||||
if limit <= 0:
|
||||
return False
|
||||
now = time.time()
|
||||
with _rate_lock:
|
||||
recent = [t for t in _rate.get(ip, []) if now - t < RATE_WINDOW_SECONDS]
|
||||
if len(recent) >= limit:
|
||||
_rate[ip] = recent
|
||||
return True
|
||||
recent.append(now)
|
||||
_rate[ip] = recent
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSP — mira a rendere sicuro il contenuto iniettato nel reader (file HTML).
|
||||
# Tutti gli script frontend (epub.js, pdf.js, jszip, DOMPurify) sono
|
||||
# self-hosted in /static/vendor → nessuna allowlist CDN necessaria.
|
||||
# default-src 'self' blocca script da CDN di terze parti. L'hardening del
|
||||
# contenuto iniettato sta nella SANITIZZAZIONE (DOMPurify + formatter) — la
|
||||
# CSP qui dà ridondanza per i vettori che violano la whitelist.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CSP_DEFAULT = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' "
|
||||
"'unsafe-inline'; " # code inline per EPUB/pdf init
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-src 'self' blob:; "
|
||||
"worker-src 'self' blob:; " # worker instanziato da pdf.js/EPUB
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
|
||||
def _apply_csp(response):
|
||||
"""Inietta l'header CSP nel response se non presente."""
|
||||
if not response.headers.get("Content-Security-Policy"):
|
||||
response.headers["Content-Security-Policy"] = CSP_DEFAULT
|
||||
return response
|
||||
|
||||
|
||||
def register_security(app):
|
||||
"""Hook pre/post request: auth, rate-limit, header hardening."""
|
||||
|
||||
@app.before_request
|
||||
def _security_before():
|
||||
auth_user = _cfg("AUTH_USERNAME")
|
||||
auth_pass = _cfg("AUTH_PASSWORD")
|
||||
if auth_user and auth_pass:
|
||||
auth = request.authorization
|
||||
# Aperti senza login: solo healthz (healthcheck Docker) e static
|
||||
# (il browser ne ha bisogno per il rendering della pagina).
|
||||
if not (request.path.startswith("/static")
|
||||
or request.path == "/healthz"):
|
||||
ok = False
|
||||
if auth:
|
||||
user = auth.username or ""
|
||||
pwd = auth.password or ""
|
||||
ok = hmac.compare_digest(user, auth_user) and \
|
||||
hmac.compare_digest(pwd, auth_pass)
|
||||
if not ok:
|
||||
resp = jsonify({"error": "Autenticazione richiesta"})
|
||||
resp.status_code = 401
|
||||
resp.headers["WWW-Authenticate"] = 'Basic realm="Bookish"'
|
||||
return resp
|
||||
|
||||
rate_limit = _cfg("RATE_LIMIT_UPLOAD", 0) or 0
|
||||
if request.method == "POST" and request.path == "/upload" and rate_limit > 0:
|
||||
ip = request.remote_addr or "unknown"
|
||||
if _rate_limited_ip(ip, rate_limit):
|
||||
return jsonify({
|
||||
"error": "Troppe richieste. Riprova tra qualche minuto.",
|
||||
"retry_after": RATE_WINDOW_SECONDS,
|
||||
}), 429
|
||||
|
||||
@app.after_request
|
||||
def _security_headers(response):
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
_apply_csp(response)
|
||||
return response
|
||||
@@ -50,26 +50,29 @@ def cleanup_task(task_id):
|
||||
def generate_sse_events(task_id):
|
||||
"""Genera eventi SSE finché la conversione non è completata o in errore."""
|
||||
prev_progress = -1
|
||||
while True:
|
||||
status = get_status(task_id)
|
||||
if not status:
|
||||
yield f"event: error\ndata: {json.dumps({'message': 'Task not found'})}\n\n"
|
||||
break
|
||||
try:
|
||||
while True:
|
||||
status = get_status(task_id)
|
||||
if not status:
|
||||
yield f"event: error\ndata: {json.dumps({'message': 'Task not found'})}\n\n"
|
||||
return
|
||||
|
||||
progress = status["progress"]
|
||||
if progress != prev_progress or status["status"] in ("completed", "error"):
|
||||
data = {
|
||||
"progress": progress,
|
||||
"status": status["status"],
|
||||
}
|
||||
if status.get("error"):
|
||||
data["error"] = status["error"]
|
||||
if status.get("output_file"):
|
||||
data["download_url"] = f"/download/{status['output_file']}"
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
prev_progress = progress
|
||||
progress = status["progress"]
|
||||
if progress != prev_progress or status["status"] in ("completed", "error"):
|
||||
data = {
|
||||
"progress": progress,
|
||||
"status": status["status"],
|
||||
}
|
||||
if status.get("error"):
|
||||
data["error"] = status["error"]
|
||||
if status.get("output_file"):
|
||||
data["download_url"] = f"/download/{status['output_file']}"
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
prev_progress = progress
|
||||
|
||||
if status["status"] in ("completed", "error"):
|
||||
break
|
||||
if status["status"] in ("completed", "error"):
|
||||
return
|
||||
|
||||
sleep(0.3)
|
||||
sleep(0.3)
|
||||
finally:
|
||||
cleanup_task(task_id)
|
||||
+273
-12
@@ -325,8 +325,15 @@ body {
|
||||
|
||||
/* === Reader Page === */
|
||||
.reader-page {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.reader-empty {
|
||||
@@ -425,10 +432,208 @@ body {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Toolbar group (width slider) */
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.toolbar-group svg {
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.width-slider {
|
||||
width: 72px;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
accent-color: #007aff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.width-slider:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.width-label {
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Help button */
|
||||
.btn-help {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
background: #f5f5f7;
|
||||
border: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-help:hover {
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
/* Help overlay modal */
|
||||
.help-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.help-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.help-modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
width: min(440px, 90vw);
|
||||
max-height: min(520px, 80vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.help-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.2rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.help-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-body {
|
||||
padding: 0.8rem 1.2rem 1.2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.help-section h4 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #007aff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.help-section p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.help-row span:last-child {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.help-row kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.7rem;
|
||||
font-family: inherit;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #d0d0d5;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 0 #ccc;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 0.75rem;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
.reader-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: #1d1d1f;
|
||||
color: #fff;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
z-index: 300;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reader-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* Bookmark button flash */
|
||||
.bm-flash {
|
||||
animation: bmPulse 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes bmPulse {
|
||||
0% { transform: scale(1); }
|
||||
30% { transform: scale(1.25); color: #007aff; }
|
||||
60% { transform: scale(1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Reader Layout */
|
||||
.reader-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 100px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.reader-sidebar {
|
||||
@@ -440,6 +645,22 @@ body {
|
||||
transition: margin-left 0.3s;
|
||||
}
|
||||
|
||||
.reader-view-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.reader-view {
|
||||
width: 100%;
|
||||
max-width: var(--reader-width, 100%);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.reader-sidebar.collapsed {
|
||||
margin-left: -280px;
|
||||
}
|
||||
@@ -527,19 +748,59 @@ body {
|
||||
}
|
||||
|
||||
#bookmarksList li {
|
||||
padding: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.reader-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
#bookmarksList li:hover {
|
||||
background: #f8f8fa;
|
||||
}
|
||||
|
||||
.bm-entry {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bm-label {
|
||||
display: block;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bm-time {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.bm-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #ccc;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.bm-delete:hover {
|
||||
color: #e74c3c;
|
||||
background: #fef0ef;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#epubViewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -560,8 +821,8 @@ body {
|
||||
}
|
||||
|
||||
.text-viewer {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 700px;
|
||||
padding: 2rem 4rem;
|
||||
max-width: min(800px, 100% - 6rem);
|
||||
margin: 0 auto;
|
||||
line-height: 1.8;
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
|
||||
currentTaskId = data.task_id;
|
||||
readerFileId = data.filename;
|
||||
readerFileId = data.stored_filename || data.filename;
|
||||
|
||||
// Connect SSE for progress
|
||||
connectSSE(currentTaskId);
|
||||
|
||||
+367
-43
@@ -20,6 +20,11 @@
|
||||
// --- Shared Reader Controls ---
|
||||
initReaderControls();
|
||||
|
||||
// Carica segnalibri e posizione dal server (se disponibile)
|
||||
if (BOOKISH_HASH) {
|
||||
loadBookmarksFromServer();
|
||||
}
|
||||
|
||||
// === EPUB Reader (epub.js) ===
|
||||
function initEpubReader(file) {
|
||||
const viewer = document.getElementById("epubViewer");
|
||||
@@ -56,9 +61,9 @@
|
||||
if (e.key === "-") changeFontSize(rendition, -2);
|
||||
});
|
||||
|
||||
// Toolbar controls
|
||||
document.getElementById("zoomIn").addEventListener("click", () => rendition.next());
|
||||
document.getElementById("zoomOut").addEventListener("click", () => rendition.prev());
|
||||
// Zoom controls
|
||||
document.getElementById("zoomIn").addEventListener("click", () => changeFontSize(rendition, 2));
|
||||
document.getElementById("zoomOut").addEventListener("click", () => changeFontSize(rendition, -2));
|
||||
|
||||
// Store ref for theme
|
||||
window._rendition = rendition;
|
||||
@@ -74,7 +79,7 @@
|
||||
if (!viewer) return;
|
||||
|
||||
const pdfjsLib = window.pdfjsLib;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = BOOKISH_PDF_WORKER;
|
||||
|
||||
let pdfDoc = null;
|
||||
let currentPage = 1;
|
||||
@@ -146,34 +151,26 @@
|
||||
window._pdfViewer = { pdfDoc, renderPage, getCurrentPage: () => currentPage, totalPages };
|
||||
}
|
||||
|
||||
// === Text Viewer (HTML, MD, TXT, FB2, DOCX via Pandoc) ===
|
||||
// === Text Viewer (HTML, MD, TXT, FB2, DOCX via Pandoc) ===
|
||||
function initTextViewer(file, ext) {
|
||||
const viewer = document.getElementById("textViewer");
|
||||
if (!viewer) return;
|
||||
|
||||
// Convert text via Pandoc on backend
|
||||
fetch(`/reader/serve/${encodeURIComponent(file)}`)
|
||||
.then((r) => r.text())
|
||||
.then((content) => {
|
||||
if (ext === "md" || ext === "fb2" || ext === "docx") {
|
||||
// Pandoc converts these to HTML on load for reader
|
||||
// But we need the HTML version. Try to get pre-converted HTML.
|
||||
fetch(`/reader/serve/${encodeURIComponent(file)}`)
|
||||
.then((r) => r.text())
|
||||
.then((text) => {
|
||||
// For plain text (md, txt), render as HTML with simple formatting
|
||||
if (ext === "md") {
|
||||
viewer.innerHTML = simpleMarkdownToHtml(text);
|
||||
} else {
|
||||
viewer.innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:1rem;line-height:1.8;">${escapeHtml(text)}</pre>`;
|
||||
}
|
||||
});
|
||||
.then((text) => {
|
||||
if (ext === "md") {
|
||||
viewer.innerHTML = simpleMarkdownToHtml(text);
|
||||
} else if (ext === "html") {
|
||||
// Direct HTML content
|
||||
viewer.innerHTML = content;
|
||||
// R3: sanitizza l'HTML iniettato (un file HTML malevolo
|
||||
// potrebbe eseguire script nel dominio dell'app).
|
||||
viewer.innerHTML = window.DOMPurify
|
||||
? DOMPurify.sanitize(text)
|
||||
: text;
|
||||
fixLinks(viewer);
|
||||
} else {
|
||||
viewer.textContent = content;
|
||||
viewer.innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:1rem;line-height:1.8;">${escapeHtml(text)}</pre>`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -232,6 +229,34 @@
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
const searchResults = document.getElementById("searchResults");
|
||||
const fullscreenToggle = document.getElementById("fullscreenToggle");
|
||||
const widthSlider = document.getElementById("widthSlider");
|
||||
const widthLabel = document.getElementById("widthLabel");
|
||||
|
||||
// Reader width slider
|
||||
if (widthSlider && widthLabel) {
|
||||
const saved = localStorage.getItem("bookish_reader_width") || "100";
|
||||
widthSlider.value = saved;
|
||||
widthLabel.textContent = saved + "%";
|
||||
document.documentElement.style.setProperty("--reader-width", saved + "%");
|
||||
|
||||
widthSlider.addEventListener("input", () => {
|
||||
const val = widthSlider.value;
|
||||
widthLabel.textContent = val + "%";
|
||||
document.documentElement.style.setProperty("--reader-width", val + "%");
|
||||
localStorage.setItem("bookish_reader_width", val);
|
||||
|
||||
// Resize epub.js rendition to match new container width
|
||||
if (window._rendition) {
|
||||
const viewer = document.getElementById("epubViewer");
|
||||
if (viewer) {
|
||||
const rect = viewer.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
window._rendition.resize(rect.width, rect.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar toggle
|
||||
if (sidebarToggle && sidebar) {
|
||||
@@ -292,6 +317,45 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Help modal toggle
|
||||
const helpToggle = document.getElementById("helpToggle");
|
||||
const helpOverlay = document.getElementById("helpOverlay");
|
||||
const helpClose = document.getElementById("helpClose");
|
||||
function toggleHelp(show) {
|
||||
if (!helpOverlay) return;
|
||||
if (show === undefined) show = !helpOverlay.classList.contains("open");
|
||||
helpOverlay.classList.toggle("open", show);
|
||||
}
|
||||
if (helpToggle) {
|
||||
helpToggle.addEventListener("click", () => toggleHelp(true));
|
||||
}
|
||||
if (helpClose) {
|
||||
helpClose.addEventListener("click", () => toggleHelp(false));
|
||||
}
|
||||
if (helpOverlay) {
|
||||
helpOverlay.addEventListener("click", (e) => {
|
||||
if (e.target === helpOverlay) toggleHelp(false);
|
||||
});
|
||||
}
|
||||
// Keyboard: Esc to close help
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") toggleHelp(false);
|
||||
});
|
||||
|
||||
// Bookmark button
|
||||
const bookmarkToggle = document.getElementById("bookmarkToggle");
|
||||
if (bookmarkToggle) {
|
||||
bookmarkToggle.addEventListener("click", () => addBookmark());
|
||||
}
|
||||
|
||||
// Keyboard shortcut for bookmark (Ctrl+B / Cmd+B)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
||||
e.preventDefault();
|
||||
addBookmark();
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved theme
|
||||
const savedTheme = localStorage.getItem("bookish_theme");
|
||||
if (savedTheme) {
|
||||
@@ -300,6 +364,9 @@
|
||||
applyTheme(t);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Init bookmarks list
|
||||
renderBookmarks();
|
||||
}
|
||||
|
||||
function applyTheme(t) {
|
||||
@@ -399,8 +466,18 @@
|
||||
}
|
||||
|
||||
function updateActiveToc(loc, toc, book) {
|
||||
// epub.js provides location.start.cfi
|
||||
document.querySelectorAll("#tocList li").forEach((l) => l.classList.remove("active"));
|
||||
if (!loc || !loc.start || !toc || !book) return;
|
||||
const href = loc.start.href;
|
||||
if (!href) return;
|
||||
const items = document.querySelectorAll("#tocList li");
|
||||
for (const li of items) {
|
||||
if (li.textContent && href.includes(li.textContent.trim())) {
|
||||
li.classList.add("active");
|
||||
li.scrollIntoView({ block: "nearest" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Search ===
|
||||
@@ -477,30 +554,67 @@
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// === Position Save/Restore ===
|
||||
// === Position Save/Restore (server + localStorage fallback) ===
|
||||
|
||||
let _positionSaveTimer = null;
|
||||
|
||||
function savePosition(book, loc) {
|
||||
if (!loc || !loc.start) return;
|
||||
const data = {
|
||||
type: "epub",
|
||||
cfi: loc.start.cfi,
|
||||
href: loc.start.href,
|
||||
chapter_title: document.getElementById("chapterTitle").textContent,
|
||||
};
|
||||
|
||||
// Fallback localStorage
|
||||
try {
|
||||
const key = `bookish_pos_${book.url() || "unknown"}`;
|
||||
const data = {
|
||||
cfi: loc.start ? loc.start.cfi : null,
|
||||
chapter: loc.start ? loc.start.href : null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
const key = "bookish_pos_" + (BOOKISH_HASH || BOOKISH_FILE || "unknown");
|
||||
localStorage.setItem(key, JSON.stringify({ ...data, timestamp: Date.now() }));
|
||||
} catch (e) {}
|
||||
|
||||
// Server (debounced)
|
||||
if (_positionSaveTimer) clearTimeout(_positionSaveTimer);
|
||||
_positionSaveTimer = setTimeout(() => {
|
||||
if (BOOKISH_HASH) {
|
||||
fetch(`/api/position/${encodeURIComponent(BOOKISH_HASH)}`, {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(data),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function restorePosition(book, rendition, filename) {
|
||||
try {
|
||||
const key = `bookish_pos_${filename}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
if (data.cfi) {
|
||||
rendition.display(data.cfi);
|
||||
async function restorePosition(book, rendition, filename) {
|
||||
let cfi = null;
|
||||
|
||||
// Prova server
|
||||
if (BOOKISH_HASH) {
|
||||
try {
|
||||
const resp = await fetch(`/api/position/${encodeURIComponent(BOOKISH_HASH)}`);
|
||||
const data = await resp.json();
|
||||
if (data.position && data.position.cfi) {
|
||||
cfi = data.position.cfi;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Fallback localStorage
|
||||
if (!cfi) {
|
||||
try {
|
||||
const key = "bookish_pos_" + (BOOKISH_HASH || BOOKISH_FILE || filename);
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
cfi = data.cfi;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (cfi) {
|
||||
rendition.display(cfi);
|
||||
}
|
||||
}
|
||||
|
||||
// === Reader Upload ===
|
||||
@@ -553,8 +667,8 @@
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.task_id) {
|
||||
const ext = file.name.split(".").pop().toLowerCase();
|
||||
const encoded = encodeURIComponent(data.filename);
|
||||
const stored = data.stored_filename || data.filename;
|
||||
const encoded = encodeURIComponent(stored);
|
||||
// We'll just redirect to reader page after upload completes
|
||||
// For simplicity, redirect immediately
|
||||
window.location.href = `/reader/${encoded}`;
|
||||
@@ -586,6 +700,216 @@
|
||||
}
|
||||
}
|
||||
|
||||
// === Bookmarks ===
|
||||
|
||||
function getBookmarksKey() {
|
||||
return "bookish_bookmarks_" + (BOOKISH_HASH || BOOKISH_FILE || "");
|
||||
}
|
||||
|
||||
function getBookmarksLocal() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(getBookmarksKey()) || "[]");
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveBookmarksLocal(list) {
|
||||
try {
|
||||
localStorage.setItem(getBookmarksKey(), JSON.stringify(list));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
let _bookmarksCache = null;
|
||||
|
||||
async function loadBookmarksFromServer() {
|
||||
if (!BOOKISH_HASH) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/bookmarks/${encodeURIComponent(BOOKISH_HASH)}`);
|
||||
const data = await resp.json();
|
||||
if (data.bookmarks && data.bookmarks.length > 0) {
|
||||
_bookmarksCache = data.bookmarks;
|
||||
saveBookmarksLocal(data.bookmarks);
|
||||
}
|
||||
} catch (e) {
|
||||
// Server unreachable — localStorage fallback handled in getBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
function getBookmarks() {
|
||||
return _bookmarksCache || getBookmarksLocal();
|
||||
}
|
||||
|
||||
async function syncBookmarkToServer(bm) {
|
||||
if (!BOOKISH_HASH) return;
|
||||
try {
|
||||
await fetch("/api/bookmarks", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(bm),
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently fail — data is safe in localStorage
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDeleteToServer(id) {
|
||||
if (!BOOKISH_HASH) return;
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function addBookmark() {
|
||||
const label = document.getElementById("chapterTitle");
|
||||
const chapter = label ? label.textContent : "Posizione corrente";
|
||||
|
||||
let data = { label: chapter || "Segnalibro", created: Date.now() };
|
||||
|
||||
if (ext === "epub" && window._rendition) {
|
||||
const loc = window._rendition.currentLocation();
|
||||
if (loc && loc.start) {
|
||||
data.type = "epub";
|
||||
data.cfi = loc.start.cfi;
|
||||
data.href = loc.start.href;
|
||||
data.page = loc.start.displayed ? loc.start.displayed.page : 0;
|
||||
data.label = chapter + " — Pag. " + data.page;
|
||||
} else {
|
||||
// Fallback quando epub.js non ha ancora restituito una posizione valida
|
||||
data.type = "epub";
|
||||
data.cfi = "start";
|
||||
data.href = "#";
|
||||
data.page = 1;
|
||||
data.label = chapter + " — Pag. 1";
|
||||
showToast("Segnalibro aggiunto (posizione corrente)");
|
||||
}
|
||||
} else if (ext === "pdf" && window._pdfViewer) {
|
||||
const page = window._pdfViewer.getCurrentPage();
|
||||
data.type = "pdf";
|
||||
data.page = page;
|
||||
data.totalPages = window._pdfViewer.totalPages;
|
||||
data.label = "Pag. " + page + " di " + data.totalPages;
|
||||
} else if (["md", "txt", "html", "fb2", "docx"].includes(ext)) {
|
||||
const rv = document.getElementById("readerView");
|
||||
if (!rv) return;
|
||||
const maxScroll = rv.scrollHeight - rv.clientHeight;
|
||||
const pct = maxScroll > 0 ? Math.round((rv.scrollTop / maxScroll) * 100) : 0;
|
||||
data.type = "text";
|
||||
data.scrollPct = pct;
|
||||
data.label = chapter + " — " + pct + "%";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
data.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||
data.chapter_title = chapter;
|
||||
data.content_hash = BOOKISH_HASH || "";
|
||||
|
||||
const bookmarks = getBookmarks();
|
||||
bookmarks.push(data);
|
||||
saveBookmarksLocal(bookmarks);
|
||||
_bookmarksCache = bookmarks;
|
||||
renderBookmarks();
|
||||
|
||||
syncBookmarkToServer(data);
|
||||
|
||||
flashBookmarkBtn();
|
||||
showToast("Segnalibro aggiunto — vedi la scheda Segnalibri nella sidebar");
|
||||
}
|
||||
|
||||
function flashBookmarkBtn() {
|
||||
const btn = document.getElementById("bookmarkToggle");
|
||||
if (!btn) return;
|
||||
btn.classList.add("bm-flash");
|
||||
setTimeout(() => btn.classList.remove("bm-flash"), 600);
|
||||
}
|
||||
|
||||
function removeBookmark(id) {
|
||||
let bookmarks = getBookmarks();
|
||||
bookmarks = bookmarks.filter((b) => b.id !== id);
|
||||
saveBookmarksLocal(bookmarks);
|
||||
_bookmarksCache = bookmarks;
|
||||
renderBookmarks();
|
||||
|
||||
syncDeleteToServer(id);
|
||||
}
|
||||
|
||||
function gotoBookmark(bm) {
|
||||
if (bm.type === "epub" && window._rendition && bm.cfi) {
|
||||
window._rendition.display(bm.cfi);
|
||||
} else if (bm.type === "pdf" && window._pdfViewer && bm.page) {
|
||||
window._pdfViewer.currentPage = bm.page;
|
||||
window._pdfViewer.renderPage(bm.page);
|
||||
updatePageInfoPdf(bm.page, window._pdfViewer.totalPages);
|
||||
} else if (bm.type === "text" && bm.scrollPct !== undefined) {
|
||||
const rv = document.getElementById("readerView");
|
||||
if (rv) {
|
||||
const maxScroll = rv.scrollHeight - rv.clientHeight;
|
||||
rv.scrollTop = (bm.scrollPct / 100) * maxScroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookmarks() {
|
||||
const list = document.getElementById("bookmarksList");
|
||||
if (!list) return;
|
||||
const bookmarks = getBookmarks();
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
list.innerHTML = '<li style="color:#999;cursor:default;font-style:italic;display:block;padding:0.5rem">Nessun segnalibro</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = bookmarks
|
||||
.map(
|
||||
(bm) =>
|
||||
`<li data-id="${bm.id}">
|
||||
<span class="bm-entry" title="Vai a questo segnalibro">
|
||||
<span class="bm-label">${escapeHtml(bm.label || "Segnalibro")}</span>
|
||||
<span class="bm-time">${new Date(bm.created).toLocaleDateString("it")}</span>
|
||||
</span>
|
||||
<button class="bm-delete" title="Elimina">✕</button>
|
||||
</li>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
list.querySelectorAll(".bm-entry").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const id = el.closest("li").dataset.id;
|
||||
const bm = bookmarks.find((b) => b.id === id);
|
||||
if (bm) gotoBookmark(bm);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll(".bm-delete").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const id = btn.closest("li").dataset.id;
|
||||
removeBookmark(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// === Toast notifications ===
|
||||
|
||||
function showToast(msg) {
|
||||
const existing = document.querySelector(".reader-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "reader-toast";
|
||||
toast.textContent = msg;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => toast.classList.add("show"));
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// === Utils ===
|
||||
function debounce(fn, ms) {
|
||||
let timer;
|
||||
|
||||
+10
-31
@@ -1,38 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#007aff"/>
|
||||
<stop offset="100%" stop-color="#34c759"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="page" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#fff"/>
|
||||
<stop offset="100%" stop-color="#f0f0f5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Cerchio sfondo -->
|
||||
<circle cx="256" cy="256" r="240" fill="url(#bg)"/>
|
||||
<!-- Libro aperto - pagina sinistra -->
|
||||
<path d="M128 160 L128 352 L256 384 L256 192 Z" fill="url(#page)" stroke="#e0e0e5" stroke-width="2"/>
|
||||
<!-- Libro aperto - pagina destra -->
|
||||
<path d="M384 160 L384 352 L256 384 L256 192 Z" fill="url(#page)" stroke="#e0e0e5" stroke-width="2"/>
|
||||
<!-- Copertina sinistra -->
|
||||
<path d="M120 152 L120 344 L128 352 L128 160 Z" fill="#d0d0d5" stroke="#bbb" stroke-width="1.5"/>
|
||||
<!-- Copertina destra -->
|
||||
<path d="M392 152 L392 344 L384 352 L384 160 Z" fill="#d0d0d5" stroke="#bbb" stroke-width="1.5"/>
|
||||
<!-- Dorso -->
|
||||
<path d="M248 190 L248 382 L264 382 L264 190 Z" fill="#e0e0e5" stroke="#ccc" stroke-width="1"/>
|
||||
<!-- Righe di testo sinistra -->
|
||||
<line x1="156" y1="216" x2="236" y2="232" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="156" y1="240" x2="236" y2="256" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="156" y1="264" x2="236" y2="280" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="156" y1="288" x2="220" y2="300" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Righe di testo destra -->
|
||||
<line x1="276" y1="232" x2="356" y2="216" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="276" y1="256" x2="356" y2="240" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="276" y1="280" x2="356" y2="264" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="292" y1="300" x2="356" y2="288" stroke="#999" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Icona freccia conversione in basso -->
|
||||
<circle cx="256" cy="440" r="28" fill="#fff" opacity="0.9"/>
|
||||
<path d="M244 436 L256 448 L268 436" stroke="#007aff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M256 424 L256 448" stroke="#007aff" stroke-width="4" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="30" fill="url(#bg)"/>
|
||||
<path d="M17 20v26l15 4V24Z" fill="#fff"/>
|
||||
<path d="M47 20v26l-15 4V24Z" fill="#fff"/>
|
||||
<path d="M15 19v24l2 2V20Z" fill="#d0d0d5"/>
|
||||
<path d="M49 19v24l-2 2V20Z" fill="#d0d0d5"/>
|
||||
<path d="M31 24v25l2 1V24Z" fill="#e0e0e5"/>
|
||||
<circle cx="32" cy="54" r="7" fill="#fff"/>
|
||||
<path d="M28 51l4 4 4-4" stroke="#007aff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M32 48v7" stroke="#007aff" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 798 B |
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e(require("JSZip")):"function"==typeof define&&define.amd?define(["JSZip"],e):"object"==typeof exports?exports.ePub=e(require("JSZip")):t.ePub=e(t.JSZip)}(window,(function(t){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var s=e[n]={i:n,l:!1,exports:{}};return t[n].call(s.exports,s,s.exports,i),s.l=!0,s.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var s in t)i.d(n,s,function(e){return t[e]}.bind(null,s));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="/dist/",i(i.s=30)}([function(t,e,i){"use strict";i.r(e),i.d(e,"requestAnimationFrame",(function(){return s})),i.d(e,"uuid",(function(){return o})),i.d(e,"documentHeight",(function(){return a})),i.d(e,"isElement",(function(){return h})),i.d(e,"isNumber",(function(){return l})),i.d(e,"isFloat",(function(){return c})),i.d(e,"prefixed",(function(){return u})),i.d(e,"defaults",(function(){return d})),i.d(e,"extend",(function(){return f})),i.d(e,"insert",(function(){return p})),i.d(e,"locationOf",(function(){return g})),i.d(e,"indexOfSorted",(function(){return m})),i.d(e,"bounds",(function(){return v})),i.d(e,"borders",(function(){return y})),i.d(e,"nodeBounds",(function(){return b})),i.d(e,"windowBounds",(function(){return w})),i.d(e,"indexOfNode",(function(){return x})),i.d(e,"indexOfTextNode",(function(){return E})),i.d(e,"indexOfElementNode",(function(){return S})),i.d(e,"isXml",(function(){return N})),i.d(e,"createBlob",(function(){return _})),i.d(e,"createBlobUrl",(function(){return T})),i.d(e,"revokeBlobUrl",(function(){return C})),i.d(e,"createBase64Url",(function(){return O})),i.d(e,"type",(function(){return I})),i.d(e,"parse",(function(){return R})),i.d(e,"qs",(function(){return k})),i.d(e,"qsa",(function(){return A})),i.d(e,"qsp",(function(){return L})),i.d(e,"sprint",(function(){return j})),i.d(e,"treeWalker",(function(){return D})),i.d(e,"walk",(function(){return P})),i.d(e,"blob2base64",(function(){return M})),i.d(e,"defer",(function(){return z})),i.d(e,"querySelectorByType",(function(){return B})),i.d(e,"findChildren",(function(){return q})),i.d(e,"parents",(function(){return F})),i.d(e,"filterChildren",(function(){return U})),i.d(e,"getParentByTagName",(function(){return W})),i.d(e,"RangeObject",(function(){return H}));var n=i(15);const s="undefined"!=typeof window&&(window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame),r="undefined"!=typeof URL?URL:"undefined"!=typeof window?window.URL||window.webkitURL||window.mozURL:void 0;function o(){var t=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(e){var i=(t+16*Math.random())%16|0;return t=Math.floor(t/16),("x"==e?i:7&i|8).toString(16)}))}function a(){return Math.max(document.documentElement.clientHeight,document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight)}function h(t){return!(!t||1!=t.nodeType)}function l(t){return!isNaN(parseFloat(t))&&isFinite(t)}function c(t){let e=parseFloat(t);return!1!==l(t)&&("string"==typeof t&&t.indexOf(".")>-1||Math.floor(e)!==e)}function u(t){var e=["-webkit-","-webkit-","-moz-","-o-","-ms-"],i=t.toLowerCase(),n=["Webkit","webkit","Moz","O","ms"].length;if("undefined"==typeof document||void 0!==document.body.style[i])return t;for(var s=0;s<n;s++)if(void 0!==document.body.style[e[s]+i])return e[s]+i;return t}function d(t){for(var e=1,i=arguments.length;e<i;e++){var n=arguments[e];for(var s in n)void 0===t[s]&&(t[s]=n[s])}return t}function f(t){var e=[].slice.call(arguments,1);return e.forEach((function(e){e&&Object.getOwnPropertyNames(e).forEach((function(i){Object.defineProperty(t,i,Object.getOwnPropertyDescriptor(e,i))}))})),t}function p(t,e,i){var n=g(t,e,i);return e.splice(n,0,t),n}function g(t,e,i,n,s){var r,o=n||0,a=s||e.length,h=parseInt(o+(a-o)/2);return i||(i=function(t,e){return t>e?1:t<e?-1:t==e?0:void 0}),a-o<=0?h:(r=i(e[h],t),a-o==1?r>=0?h:h+1:0===r?h:-1===r?g(t,e,i,h,a):g(t,e,i,o,h))}function m(t,e,i,n,s){var r,o=n||0,a=s||e.length,h=parseInt(o+(a-o)/2);return i||(i=function(t,e){return t>e?1:t<e?-1:t==e?0:void 0}),a-o<=0?-1:(r=i(e[h],t),a-o==1?0===r?h:-1:0===r?h:-1===r?m(t,e,i,h,a):m(t,e,i,o,h))}function v(t){var e=window.getComputedStyle(t),i=0,n=0;return["width","padLine truncated
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/*!
|
||||
|
||||
JSZip v3.10.1 - A JavaScript class for generating and reading zip files
|
||||
<http://stuartk.com/jszip>
|
||||
|
||||
(c) 2009-2016 Stuart Knightley <stuart [at] stuartk.com>
|
||||
Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown.
|
||||
|
||||
JSZip uses the library pako released under the MIT license :
|
||||
https://github.com/nodeca/pako/blob/main/LICENSE
|
||||
*/
|
||||
|
||||
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e<h.length;e++)u(h[e]);return u}({1:[function(e,t,r){"use strict";var d=e("./utils"),c=e("./support"),p="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";r.encode=function(e){for(var t,r,n,i,s,a,o,h=[],u=0,l=e.length,f=l,c="string"!==d.getTypeOf(e);u<e.length;)f=l-u,n=c?(t=e[u++],r=u<l?e[u++]:0,u<l?e[u++]:0):(t=e.charCodeAt(u++),r=u<l?e.charCodeAt(u++):0,u<l?e.charCodeAt(u++):0),i=t>>2,s=(3&t)<<4|r>>4,a=1<f?(15&r)<<2|n>>6:64,o=2<f?63&n:64,h.push(p.charAt(i)+p.charAt(s)+p.charAt(a)+p.charAt(o));return h.join("")},r.decode=function(e){var t,r,n,i,s,a,o=0,h=0,u="data:";if(e.substr(0,u.length)===u)throw new Error("Invalid base64 input, it looks like a data url.");var l,f=3*(e=e.replace(/[^A-Za-z0-9+/=]/g,"")).length/4;if(e.charAt(e.length-1)===p.charAt(64)&&f--,e.charAt(e.length-2)===p.charAt(64)&&f--,f%1!=0)throw new Error("Invalid base64 input, bad content length.");for(l=c.uint8array?new Uint8Array(0|f):new Array(0|f);o<e.length;)t=p.indexOf(e.charAt(o++))<<2|(i=p.indexOf(e.charAt(o++)))>>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(Line truncated
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*
|
||||
* Copyright 2023 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @licend The above is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*/
|
||||
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=t.pdfjsLib=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],(()=>t.pdfjsLib=e())):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=t.pdfjsLib=e():t["pdfjs-dist/build/pdf"]=t.pdfjsLib=e()}(globalThis,(()=>(()=>{"use strict";var __webpack_modules__=[,(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.VerbosityLevel=e.Util=e.UnknownErrorException=e.UnexpectedResponseException=e.TextRenderingMode=e.RenderingIntentFlag=e.PromiseCapability=e.PermissionFlag=e.PasswordResponses=e.PasswordException=e.PageActionEventType=e.OPS=e.MissingPDFException=e.MAX_IMAGE_SIZE_TO_CACHE=e.LINE_FACTOR=e.LINE_DESCENT_FACTOR=e.InvalidPDFException=e.ImageKind=e.IDENTITY_MATRIX=e.FormatError=e.FeatureTest=e.FONT_IDENTITY_MATRIX=e.DocumentActionEventType=e.CMapCompressionType=e.BaseException=e.BASELINE_FACTOR=e.AnnotationType=e.AnnotationReplyType=e.AnnotationPrefix=e.AnnotationMode=e.AnnotationFlag=e.AnnotationFieldFlag=e.AnnotationEditorType=e.AnnotationEditorPrefix=e.AnnotationEditorParamsType=e.AnnotationBorderStyleType=e.AnnotationActionEventType=e.AbortException=void 0;e.assert=function assert(t,e){t||unreachable(e)};e.bytesToString=bytesToString;e.createValidAbsoluteUrl=function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=stringToUTF8String(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(s))return s}catch{}return null};e.getModificationDate=function getModificationDate(t=new Date){return[t.getUTCFullYear().toString(),(t.getUTCMonth()+1).toString().padStart(2,"0"),t.getUTCDate().toString().padStart(2,"0"),t.getUTCHours().toString().padStart(2,"0"),t.getUTCMinutes().toString().padStart(2,"0"),t.getUTCSeconds().toString().padStart(2,"0")].join("")};e.getUuid=function getUuid(){if("undefined"!=typeof crypto&&"function"==typeof crypto?.randomUUID)return crypto.randomUUID();const t=new Uint8Array(32);if("undefined"!=typeof crypto&&"function"==typeof crypto?.getRandomValues)crypto.getRandomValues(t);else for(let e=0;e<32;e++)t[e]=Math.floor(255*Math.random());return bytesToString(t)};e.getVerbosityLevel=function getVerbosityLevel(){return n};e.info=function info(t){n>=s.INFOS&&console.log(`Info: ${t}`)};e.isArrayBuffer=function isArrayBuffer(t){return"object"==typeof t&&void 0!==t?.byteLength};e.isArrayEqual=function isArrayEqual(t,e){if(t.length!==e.length)return!1;for(let i=0,s=t.length;i<s;i++)if(t[i]!==e[i])return!1;return!0};e.isNodeJS=void 0;e.normalizeUnicode=function normalizeUnicode(t){if(!l){l=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;h=new Map([["ſt","ſt"]])}return t.replaceAll(l,((t,e,i)=>e?e.normalize("NFKC"):h.get(i)))};e.objectFromMap=function objectFromMap(t){const e=Object.create(null);for(const[i,s]of t)e[i]=s;return e};e.objectSize=function objectSize(t){return Object.keys(t).length};e.setVerbosityLevel=function setVerbosityLevel(t){Number.isInteger(t)&&(n=t)};e.shadow=shadow;e.string32=function string32(t){return String.fromCharCode(t>>24&255,t>>16&255,t>>8&255,255&t)};e.stringToBytes=stringToBytes;e.stringToPDFString=function stringToPDFString(t){if(t[0]>="ï"){let e;"þ"===t[0]&&"ÿ"===t[1]?e="utf-16be":"ÿ"===t[0]&&"þ"===t[1]?e="utf-16le":"ï"===t[0]&&"»"===t[1]&&"¿"===t[2]&&(e="utf-8");if(e)try{const i=new TextDecoder(e,{fatal:!0}),s=stringToBytes(t);return i.decode(s)}catch(t){warn(`stringToPDFString: "${t}".`)}}const e=[];for(let i=0,s=t.length;i<s;i++){const s=o[t.charCodeAt(i)];e.push(s?String.fromCharCode(s):t.charAt(i))}return e.join("")};e.stringToUTF8String=stringToUTF8String;e.unreachable=unreachable;e.utf8StringToString=function utf8StringToString(t){return unescape(encodeURIComponent(t))};e.warn=warn;const i=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type);e.isNodeJS=i;e.IDENTITY_MATRIX=[1,0,0,1,0,0];e.FONT_IDENTITY_MATRIX=[.001,0,0,.001,0,0];e.MAX_IMAGE_SIZE_TO_CACHE=1e7;e.LINE_FACTOR=1.35;e.LINE_DESCENT_FACTOR=.35;e.BASELINE_FACTOR=.25925925925925924;e.RenderingIntentFlag={ANY:1,DISPLAY:2,PRINT:4,SAVE:8,ANNOTATIONS_FORMS:16,ANNOTATIONS_STORAGE:32,ANNOTATIONS_DISABLE:64,OPLIST:256};e.AnnotationMode={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3};e.AnnotationEditorPrefix="pdfjs_internal_editor_";e.AnnotationEditorType={DISABLE:-1,NONE:0,FREETEXT:3,STLine truncated
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*
|
||||
* Copyright 2023 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @licend The above is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*/
|
||||
!function webpackUniversalModuleDefinition(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=e.pdfjsWorker=t():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf.worker",[],(()=>e.pdfjsWorker=t())):"object"==typeof exports?exports["pdfjs-dist/build/pdf.worker"]=e.pdfjsWorker=t():e["pdfjs-dist/build/pdf.worker"]=e.pdfjsWorker=t()}(globalThis,(()=>(()=>{"use strict";var e=[,(e,t,a)=>{Object.defineProperty(t,"__esModule",{value:!0});t.WorkerTask=t.WorkerMessageHandler=void 0;var r=a(2),n=a(3),i=a(4),s=a(6),o=a(10),c=a(68),l=a(73),h=a(104),u=a(105),d=a(72);class WorkerTask{constructor(e){this.name=e;this.terminated=!1;this._capability=new r.PromiseCapability}get finished(){return this._capability.promise}finish(){this._capability.resolve()}terminate(){this.terminated=!0}ensureNotTerminated(){if(this.terminated)throw new Error("Worker task was terminated")}}t.WorkerTask=WorkerTask;class WorkerMessageHandler{static setup(e,t){let a=!1;e.on("test",(function(t){if(!a){a=!0;e.send("test",t instanceof Uint8Array)}}));e.on("configure",(function(e){(0,r.setVerbosityLevel)(e.verbosity)}));e.on("GetDocRequest",(function(e){return WorkerMessageHandler.createDocumentHandler(e,t)}))}static createDocumentHandler(e,t){let a,f=!1,g=null;const p=new Set,m=(0,r.getVerbosityLevel)(),{docId:b,apiVersion:y}=e,w="3.11.174";if(y!==w)throw new Error(`The API version "${y}" does not match the Worker version "${w}".`);const S=[];for(const e in[])S.push(e);if(S.length)throw new Error("The `Array.prototype` contains unexpected enumerable properties: "+S.join(", ")+"; thus breaking e.g. `for...in` iteration of `Array`s.");const x=b+"_worker";let C=new h.MessageHandler(x,b,t);function ensureNotTerminated(){if(f)throw new Error("Worker was terminated")}function startWorkerTask(e){p.add(e)}function finishWorkerTask(e){e.finish();p.delete(e)}async function loadDocument(e){await a.ensureDoc("checkHeader");await a.ensureDoc("parseStartXRef");await a.ensureDoc("parse",[e]);await a.ensureDoc("checkFirstPage",[e]);await a.ensureDoc("checkLastPage",[e]);const t=await a.ensureDoc("isPureXfa");if(t){const e=new WorkerTask("loadXfaFonts");startWorkerTask(e);await Promise.all([a.loadXfaFonts(C,e).catch((e=>{})).then((()=>finishWorkerTask(e))),a.loadXfaImages()])}const[r,n]=await Promise.all([a.ensureDoc("numPages"),a.ensureDoc("fingerprints")]);return{numPages:r,fingerprints:n,htmlForXfa:t?await a.ensureDoc("htmlForXfa"):null}}function getPdfManager({data:e,password:t,disableAutoFetch:a,rangeChunkSize:i,length:o,docBaseUrl:c,enableXfa:l,evaluatorOptions:h}){const d={source:null,disableAutoFetch:a,docBaseUrl:c,docId:b,enableXfa:l,evaluatorOptions:h,handler:C,length:o,password:t,rangeChunkSize:i},f=new r.PromiseCapability;let p;if(e){try{d.source=e;p=new s.LocalPdfManager(d);f.resolve(p)}catch(e){f.reject(e)}return f.promise}let m,y=[];try{m=new u.PDFWorkerStream(C)}catch(e){f.reject(e);return f.promise}const w=m.getFullReader();w.headersReady.then((function(){if(w.isRangeSupported){d.source=m;d.length=w.contentLength;d.disableAutoFetch||=w.isStreamingSupported;p=new s.NetworkPdfManager(d);for(const e of y)p.sendProgressiveData(e);y=[];f.resolve(p);g=null}})).catch((function(e){f.reject(e);g=null}));let S=0;new Promise((function(e,t){const readChunk=function({value:e,done:a}){try{ensureNotTerminated();if(a){p||function(){const e=(0,n.arrayBuffersToBytes)(y);o&&e.length!==o&&(0,r.warn)("reported HTTP length is different from actual");try{d.source=e;p=new s.LocalPdfManager(d);f.resolve(p)}catch(e){f.reject(e)}y=[]}();g=null;return}S+=e.byteLength;w.isStreamingSupported||C.send("DocProgress",{loaded:S,total:Math.max(S,w.contentLength||0)});p?p.sendProgressiveData(e):y.push(e);w.read().then(readChunk,t)}catch(e){t(e)}};w.read().then(readChunk,t)})).catch((function(e){f.reject(e);g=null}));g=function(e){m.cancelAllRequests(e)};return f.promise}C.on("GetPage",(function(e){return a.getPage(e.pageIndex).then((function(e){return Promise.all([a.ensure(e,"rotate"),a.ensure(e,"ref"),a.ensure(e,"userUnit"),a.ensure(e,"view")]).then((function([e,t,a,r]){return{rotate:e,ref:t,userUnit:a,view:r}}))}))}));C.on("GetPageIndex",(function(e){const t=i.Ref.get(e.num,e.gen);return a.ensureCatalog("getPageIndex",[t])}));C.on("GetDestinations",(function(e){return a.ensureCatalog("destinations")}));C.on("GetDestination",(function(e){return a.ensureCatalog("getDestination",[e.id])}));C.on("GetPageLabels",(function(e){return a.ensureCatalog("pageLabels")}));C.on("GetPageLayout",(function(e){return a.ensureCatalog("pageLayout")}));C.on("GetPageMode",(function(e){return a.ensureCatalog("pageMode")}));C.on("GetViewerPreferences",(function(e){return a.ensureCatalog("viewerPreferences")}));C.on("GetOpenAction",(function(e){return a.ensureCatalog("openAction")}));C.on("GetAttachments",(function(e){return a.ensureCatalog("attachments")}));C.on("GetDocJSActions",(function(e){return a.ensureCatalog("jsActions")}));C.on("GetPageJSActions",(function({pageInLine truncated
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/*! @license DOMPurify 3.1.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.1.6/LICENSE */
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=b(Array.prototype.forEach),m=b(Array.prototype.pop),p=b(Array.prototype.push),f=b(String.prototype.toLowerCase),d=b(String.prototype.toString),h=b(String.prototype.match),g=b(String.prototype.replace),T=b(String.prototype.indexOf),y=b(String.prototype.trim),E=b(Object.prototype.hasOwnProperty),_=b(RegExp.prototype.test),A=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];return s(N,t)});var N;function b(e){return function(t){for(var n=arguments.length,o=new Array(n>1?n-1:0),r=1;r<n;r++)o[r-1]=arguments[r];return c(e,t,o)}}function S(e,o){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:f;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function R(e){for(let t=0;t<e.length;t++){E(e,t)||(e[t]=null)}return e}function w(t){const n=l(null);for(const[o,r]of e(t)){E(t,o)&&(Array.isArray(r)?n[o]=R(r):r&&"object"==typeof r&&r.constructor===Object?n[o]=w(r):n[o]=r)}return n}function C(e,t){for(;null!==e;){const n=r(e,t);if(n){if(n.get)return b(n.get);if("function"==typeof n.value)return b(n.value)}e=o(e)}return function(){return null}}const L=i(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D=i(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),v=i(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),O=i(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),x=i(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),k=i(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),M=i(["#text"]),I=i(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readLine truncated
|
||||
//# sourceMappingURL=purify.min.js.map
|
||||
@@ -14,45 +14,86 @@
|
||||
<span class="page-info" id="pageInfo">-- / --</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class="toolbar-group" title="Larghezza area di lettura">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="12" y1="3" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<input type="range" id="widthSlider" min="40" max="100" value="100" class="width-slider">
|
||||
<span class="width-label" id="widthLabel">100%</span>
|
||||
</div>
|
||||
<button class="tool-btn" id="zoomOut" title="Zoom -">A−</button>
|
||||
<button class="tool-btn" id="zoomIn" title="Zoom +">A+</button>
|
||||
<button class="tool-btn" id="themeToggle" title="Tema">☀️</button>
|
||||
<button class="tool-btn" id="searchToggle" title="Cerca">🔍</button>
|
||||
<button class="tool-btn" id="bookmarkToggle" title="Segnalibro (Ctrl+B)">🔖</button>
|
||||
<button class="tool-btn" id="fullscreenToggle" title="Schermo intero">⛶</button>
|
||||
<button class="tool-btn btn-help" id="helpToggle" title="Aiuto e scorciatoie">?</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-container">
|
||||
<div class="reader-sidebar" id="sidebar">
|
||||
<div class="sidebar-tabs">
|
||||
<button class="sidebar-tab active" data-tab="toc">Indice</button>
|
||||
<button class="sidebar-tab" data-tab="search">Cerca</button>
|
||||
<button class="sidebar-tab" data-tab="bookmarks">Segnalibri</button>
|
||||
<div class="help-overlay" id="helpOverlay">
|
||||
<div class="help-modal">
|
||||
<div class="help-header">
|
||||
<h3>Aiuto e scorciatoie</h3>
|
||||
<button class="tool-btn" id="helpClose">✕</button>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="tab-panel active" id="panel-toc">
|
||||
<ul id="tocList"><li>Caricamento...</li></ul>
|
||||
<div class="help-body">
|
||||
<div class="help-section">
|
||||
<h4>Navigazione</h4>
|
||||
<div class="help-row"><kbd>←</kbd><kbd>→</kbd><span>Pagina precedente/successiva</span></div>
|
||||
<div class="help-row"><kbd>Ctrl</kbd>+<kbd>B</kbd><span>Aggiungi/rimuovi segnalibro</span></div>
|
||||
<div class="help-row"><kbd>Ctrl</kbd>+<kbd>F</kbd><span>Cerca nel libro</span></div>
|
||||
<div class="help-row"><kbd>F</kbd><span>Schermo intero</span></div>
|
||||
</div>
|
||||
<div class="tab-panel" id="panel-search">
|
||||
<input type="text" id="searchInput" placeholder="Cerca nel libro..." class="search-input">
|
||||
<div id="searchResults"></div>
|
||||
<div class="help-section">
|
||||
<h4>Lettura</h4>
|
||||
<div class="help-row"><kbd>+</kbd><kbd>-</kbd><span>Zoom avanti/indietro (testo)</span></div>
|
||||
<div class="help-row"><span class="help-icon">☰</span><span>Mostra/nascondi indice</span></div>
|
||||
<div class="help-row"><span class="help-icon">🔖</span><span>Aggiunge segnalibro alla pagina corrente</span></div>
|
||||
<div class="help-row"><span class="help-icon">🌙 ☀️ 🟤</span><span>Cicla tema: chiaro, scuro, seppia</span></div>
|
||||
</div>
|
||||
<div class="tab-panel" id="panel-bookmarks">
|
||||
<ul id="bookmarksList"><li>Nessun segnalibro</li></ul>
|
||||
<div class="help-section">
|
||||
<h4>Slider larghezza</h4>
|
||||
<p>Regola la larghezza dell'area di lettura dal 40% al 100% dello schermo. Utile per files con righe molto lunghe.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-view" id="readerView">
|
||||
{% if file_ext == 'epub' %}
|
||||
<div id="epubViewer"></div>
|
||||
{% elif file_ext == 'pdf' %}
|
||||
<div id="pdfViewer"></div>
|
||||
{% else %}
|
||||
<div id="textViewer" class="text-viewer"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-container">
|
||||
<div class="reader-sidebar" id="sidebar">
|
||||
<div class="sidebar-tabs">
|
||||
<button class="sidebar-tab active" data-tab="toc">Indice</button>
|
||||
<button class="sidebar-tab" data-tab="search">Cerca</button>
|
||||
<button class="sidebar-tab" data-tab="bookmarks">Segnalibri</button>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="tab-panel active" id="panel-toc">
|
||||
<ul id="tocList"><li>Caricamento...</li></ul>
|
||||
</div>
|
||||
<div class="tab-panel" id="panel-search">
|
||||
<input type="text" id="searchInput" placeholder="Cerca nel libro..." class="search-input">
|
||||
<div id="searchResults"></div>
|
||||
</div>
|
||||
<div class="tab-panel" id="panel-bookmarks">
|
||||
<ul id="bookmarksList"><li>Nessun segnalibro</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-view-wrapper">
|
||||
<div class="reader-view" id="readerView">
|
||||
{% if file_ext == 'epub' %}
|
||||
<div id="epubViewer"></div>
|
||||
{% elif file_ext == 'pdf' %}
|
||||
<div id="pdfViewer"></div>
|
||||
{% else %}
|
||||
<div id="textViewer" class="text-viewer"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="reader-empty">
|
||||
<h2>Reader</h2>
|
||||
@@ -83,14 +124,17 @@
|
||||
|
||||
{% block scripts %}
|
||||
{% if file_ext == 'epub' %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/purify.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/jszip.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/epub.min.js') }}"></script>
|
||||
{% elif file_ext == 'pdf' %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/pdf.min.js') }}"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
const BOOKISH_FILE = {{ filename | tojson | safe if filename else 'null' }};
|
||||
const BOOKISH_EXT = {{ file_ext | tojson | safe if file_ext else 'null' }};
|
||||
const BOOKISH_HASH = {{ content_hash | tojson | safe if content_hash else 'null' }};
|
||||
const BOOKISH_PDF_WORKER = {{ pdf_worker_url | tojson | safe }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/reader.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,14 +1,37 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
DATA_DIR = os.path.abspath(os.environ.get("BOOKISH_DATA_DIR", BASE_DIR))
|
||||
|
||||
SECRET_KEY = os.environ.get("BOOKISH_SECRET", "cambiami-in-produzione")
|
||||
DEBUG = os.environ.get("BOOKISH_DEBUG", "true").lower() == "true"
|
||||
DEBUG = os.environ.get("BOOKISH_DEBUG", "false").lower() in {"1", "true", "yes"}
|
||||
|
||||
UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
|
||||
CONVERTED_FOLDER = os.path.join(BASE_DIR, "converted")
|
||||
_DEFAULT_SECRET = "cambiami-in-produzione"
|
||||
SECRET_KEY = os.environ.get("BOOKISH_SECRET", _DEFAULT_SECRET)
|
||||
if not DEBUG and (not SECRET_KEY or SECRET_KEY == _DEFAULT_SECRET):
|
||||
raise RuntimeError(
|
||||
"BOOKISH_SECRET deve essere impostato quando BOOKISH_DEBUG=false"
|
||||
)
|
||||
|
||||
UPLOAD_FOLDER = os.path.join(DATA_DIR, "uploads")
|
||||
CONVERTED_FOLDER = os.path.join(DATA_DIR, "converted")
|
||||
DB_PATH = os.path.join(DATA_DIR, "bookish.db")
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
CLEANUP_HOURS = 1 # ore prima di cancellare file temporanei
|
||||
CLEANUP_HOURS = int(os.environ.get("BOOKISH_CLEANUP_HOURS", "1"))
|
||||
|
||||
# Autenticazione opzionale (Basic). Se entrambe le variabili sono valorizzate,
|
||||
# tutte le route tranne healthz/static richiedono preventivamente il login.
|
||||
AUTH_USERNAME = os.environ.get("BOOKISH_AUTH_USERNAME") or None
|
||||
AUTH_PASSWORD = os.environ.get("BOOKISH_AUTH_PASSWORD") or None
|
||||
|
||||
# Rate limit: numero massimo di upload per IP nell'arco di un'ora.
|
||||
# 0 disabilita il limite.
|
||||
RATE_LIMIT_UPLOAD = int(os.environ.get("BOOKISH_RATE_LIMIT_UPLOAD", "20"))
|
||||
|
||||
# Logging: "json" abilita il formato strutturato (una riga JSON per log)
|
||||
LOG_FORMAT = os.environ.get("LOG_FORMAT", "").lower()
|
||||
|
||||
# Timeout conversioni (secondi): per documentazione/guardia via gunicorn
|
||||
CONVERSION_TIMEOUT_SECONDS = int(os.environ.get("BOOKISH_CONVERSION_TIMEOUT", "300"))
|
||||
|
||||
# Formati supportati
|
||||
SUPPORTED_FORMATS = {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
bookish:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# Riproducibilita (R4b): sha256 di requirements.lock. Default = hash
|
||||
# corrente; in dev puoi sovrascriverlo con REQUIREMENTS_CHECKSUM=""
|
||||
REQUIREMENTS_CHECKSUM: ${REQUIREMENTS_CHECKSUM:-d13e4fb14a909e0bd06d20894860152d5334aa341dd9dec2e5e4ca368a0d2a62}
|
||||
image: bookish:local
|
||||
ports:
|
||||
- "${PORT:-5000}:5000"
|
||||
environment:
|
||||
BOOKISH_SECRET: ${BOOKISH_SECRET:?Copia .env.example in .env e imposta BOOKISH_SECRET}
|
||||
BOOKISH_DATA_DIR: /data
|
||||
BOOKISH_DEBUG: "false"
|
||||
BOOKISH_CLEANUP_HOURS: ${BOOKISH_CLEANUP_HOURS:-1}
|
||||
# Sicurezza opzionale
|
||||
BOOKISH_AUTH_USERNAME: ${BOOKISH_AUTH_USERNAME:-}
|
||||
BOOKISH_AUTH_PASSWORD: ${BOOKISH_AUTH_PASSWORD:-}
|
||||
BOOKISH_RATE_LIMIT_UPLOAD: ${BOOKISH_RATE_LIMIT_UPLOAD:-20}
|
||||
# Osservabilità: "json" = logging strutturato (1 riga JSON per log)
|
||||
LOG_FORMAT: ${LOG_FORMAT:-}
|
||||
# Timeout conversioni (s); default 300 — alza per libri lunghi
|
||||
BOOKISH_CONVERSION_TIMEOUT: ${BOOKISH_CONVERSION_TIMEOUT:-300}
|
||||
PORT: "5000"
|
||||
volumes:
|
||||
- bookish-data:/data
|
||||
restart: unless-stopped
|
||||
mem_limit: 1g
|
||||
cpus: "1.0"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
stop_grace_period: 30s
|
||||
|
||||
volumes:
|
||||
bookish-data:
|
||||
+115
-81
@@ -1,98 +1,132 @@
|
||||
# Analisi Completa del Progetto Bookish
|
||||
# Analisi del Progetto Bookish
|
||||
|
||||
## 1. Analisi del Codice
|
||||
|
||||
### Qualità Generale
|
||||
- **Leggibilità**: Buona. Codice Python pulito con nomi descrittivi. JS ben strutturato.
|
||||
- **Manutenibilità**: Alta. Modulare (routes/services/static). Ogni file ha una responsabilità chiara.
|
||||
- **Type Safety**: Assente (Python). JS è vanilla senza TypeScript. Accettabile per un progetto personale.
|
||||
## 1. Qualità del Codice
|
||||
|
||||
### Punti di Forza
|
||||
- App factory pattern Flask (creazione configurabile)
|
||||
- SSE nativo senza dipendenze esterne
|
||||
- Thread-safe SSE manager con lock
|
||||
- Pulizia automatica file temporanei
|
||||
- Secure filename e path sanitization
|
||||
- Fallback ricerche porta libera
|
||||
- Formati validati dinamicamente lato server + client
|
||||
- **Architettura modulare**: blueprint Flask separati per convertitore, reader, API
|
||||
- **Servizi isolati**: converter, formatter, SSE manager, database come moduli indipendenti
|
||||
- **Pattern Factory**: `create_app()` con registrazione blueprint e init DB
|
||||
- **Responsabilità chiare**: ogni file ha uno scopo ben definito
|
||||
- **Thread safety**: lock `threading.Lock()` in SSE manager per accesso concorrente
|
||||
- **Frontend vanilla**: nessuna dipendenza framework pesante, codice lineare
|
||||
- **Path robust**: `os.path.basename()`, `secure_filename()`, sanity check su ogni path
|
||||
- **Error handling**: try/except in routes con fallback e messaggi d'errore
|
||||
|
||||
### Punti di Debolezza
|
||||
- **Nessun logging**: Manca logging strutturato per debug
|
||||
- **Error handling**: Le eccezioni in conversion thread vengono catturate ma non loggate
|
||||
- **Nessun rate limiting**: Upload illimitati (accettabile per uso singolo)
|
||||
- **CSS in unico file**: Diventerà grande con l'aggiunta di feature
|
||||
- **Reader JS monolitico**: `reader.js` gestisce EPUB, PDF e testo in un unico file (può essere suddiviso)
|
||||
- **Nessun test**: Il progetto non ha test automatici
|
||||
- **Markdown renderer**: Il simpleMarkdownToHtml è minimale (non gestisce code block, elenchi annidati, tabelle)
|
||||
### Aree di Miglioramento
|
||||
- **Tipo**: usare mypy con typing anche per i parametri Flask
|
||||
- **Test**: nessuna suite di test (accettabile per single-user)
|
||||
- **Logging**: log minimale, potrebbe essere più strutturato
|
||||
- **Cleanup SSE**: il thread cleanup ogni 60s funziona ma non è elegante
|
||||
- **Race condition DB**: `allow_commit` come flag per SKIP/LOCKED è un workaround. In pratica SQLite gestisce bene le race con WAL mode — testato con debounce 1.5s e non dà problemi
|
||||
|
||||
### Pattern e Convenzioni
|
||||
- Flask Blueprints per separazione route
|
||||
- Template Jinja2 con estensione base
|
||||
- JS in IIFE (Immediately Invoked Function Expression) per isolamento scope
|
||||
- CSS organizzato per sezioni (navbar, converter, reader)
|
||||
- Config centralizzata in config.py
|
||||
## 2. Sicurezza
|
||||
|
||||
## 2. Analisi della Sicurezza
|
||||
- ✅ **Path traversal**: `secure_filename()` + `os.path.basename()` + sanity check
|
||||
- ✅ **Estensione validata**: controllo su SUPPORTED_FORMATS/OUTPUT_FORMATS_BY_INPUT
|
||||
- ✅ **MIME check**: al download per prevenire tipo sbagliato
|
||||
- ✅ **Cleanup automatico**: file temporanei eliminati dopo 1h
|
||||
- ✅ **SQLite parametriziato**: query con placeholder `?`, niente interpolazione stringhe
|
||||
- ✅ **Niente eval/exec**: zero codice dinamico pericoloso
|
||||
- ✅ **CSRF**: non rilevante per single-user con API REST semplici
|
||||
- ⚠️ **Secret key hardcoded**: default in `app.py`. Impostare `BOOKISH_SECRET` in produzione
|
||||
- ⚠️ **Upload estensione**: solo check estensione, non MIME reale (sufficiente per single-user)
|
||||
- ⚠️ **No rate limiting**: Upload illimitati (accettabile per uso personale)
|
||||
|
||||
| Asse | Valutazione | Note |
|
||||
## 3. Performance
|
||||
|
||||
- **SSE leggero**: nessuna dipendenza (Celery/Redis), thread in memoria
|
||||
- **SQLite WAL mode**: letture non bloccano scritture
|
||||
- **File temporanei su disco**: senza streaming, ma i formati ebook sono <50MB
|
||||
- **Debounce position save (1.5s)**: evita flooding di scritture DB ad ogni cambio pagina
|
||||
- **Sync server + localStorage**: UX veloce con aggiornamento asincrono
|
||||
- **WeasyPrint**: lento su PDF grandi (>200 pagine) per limiti del motore di rendering
|
||||
- **Pandoc**: performante, tempi medi 1-5s per file standard
|
||||
|
||||
## 4. Manutenibilità
|
||||
|
||||
- **Codice auto-documentante**: nomi di funzioni e variabili chiari
|
||||
- **Config centralizzata**: `config.py` con tutti i path e formati
|
||||
- **Servizi disaccoppiati**: modificare il converter non impatta reader o API
|
||||
- **Separazione API**: le rotte `/api/` sono in blueprint dedicato
|
||||
- **reader.js separato**: anche se lungo (800+ righe), è l'unico file JS del reader
|
||||
- **CSS strutturato**: sezioni per reader, converter, generiche
|
||||
- **Documentazione completa**: README, architecture, deploy, wiki
|
||||
|
||||
## 5. Affidabilità
|
||||
|
||||
- **Porta libera automatica**: se 5000 occupata, `find_free_port()` scorre fino a trovare una libera
|
||||
- **Cleanup temp**: thread timer ogni 60 minuti elimina file scaduti
|
||||
- **Fallback localStorage**: se server API non risponde, bookmarks e posizioni funzionano comunque (solo in locale)
|
||||
- **Content hash come ID**: stesso file → stesso hash → stessi segnalibri, anche su PC diverso
|
||||
- **Auto-creazione DB**: database creato al primo avvio senza intervento manuale
|
||||
- **Gestione errori conversione**: errori Pandoc catturati e propagati come messaggio utente
|
||||
|
||||
## 6. Dipendenze
|
||||
|
||||
### Python (produzione)
|
||||
| Pacchetto | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| Path traversal | ✅ Protetto | `os.path.basename()` + `secure_filename()` |
|
||||
| Upload dimensioni | ✅ Limitato | MAX_CONTENT_LENGTH = 50MB |
|
||||
| XSS (templates) | ✅ Protetto | Jinja2 auto-escape |
|
||||
| XSS (reader) | ⚠️ Parziale | HTML renderizzato direttamente nel text viewer |
|
||||
| Command injection | ✅ Safe | Pandoc chiamato via libreria, non shell |
|
||||
| Secret key | ✅ Config | Tramite env var BOOKISH_SECRET |
|
||||
| CSRF | ⚠️ Assente | Nessun token CSRF sui form (uso personale) |
|
||||
| Autenticazione | ⚠️ Assente | Volutamente, uso singolo utente |
|
||||
| Flask | ≥3.0 | Web framework |
|
||||
| Flask-CORS | ≥4.0 | CORS per API |
|
||||
| pypandoc_binary | ≥2.0 | Pandoc binary + binding |
|
||||
| WeasyPrint | ≥62 | PDF da HTML |
|
||||
| python-docx | ≥1.0 | DOCX post-processing |
|
||||
| beautifulsoup4 | ≥4.0 | HTML/tabelle post-processing |
|
||||
| lxml | ≥5.0 | Parser XML per BeautifulSoup |
|
||||
| Werkzeug | ≥3.0 | Secure filename, HTTP utilities |
|
||||
| markdown | ≥3.0 | MD → HTML per anteprime |
|
||||
| pdfminer.six | ≥2022 | Testo base da PDF |
|
||||
|
||||
## 3. Analisi Performance
|
||||
### Frontend (browser)
|
||||
| Libreria | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| epub.js | 0.3.93 | Render EPUB |
|
||||
| pdf.js | 3.x | Render PDF |
|
||||
| PDF.js | 3.x | Text layer PDF |
|
||||
| jszip | 3.x | ZIP per EPUB |
|
||||
|
||||
- **Conversione**: Pandoc è veloce (ebook < 1 secondo). PDF via WeasyPrint può richiedere qualche secondo.
|
||||
- **Thread**: Ogni conversione è un thread separato. Per uso singolo non ci sono problemi.
|
||||
- **SSE**: Connessione keep-alive leggera, ~300 bytes ogni 300ms.
|
||||
- **Reader**: epub.js rende tutto lato client. File EPUB fino a 50MB senza problemi.
|
||||
- **Memoria**: L'app in idle usa ~50MB. Durante conversione PDF ~150MB (WeasyPrint).
|
||||
|
||||
## 4. Analisi Modularità
|
||||
## 7. Architettura Componenti
|
||||
|
||||
```
|
||||
bookish/
|
||||
├── routes/ ⬅️ Separati per dominio (convert vs reader)
|
||||
│ ├── convert.py
|
||||
│ └── reader.py
|
||||
├── services/ ⬅️ Logica di business isolata dal routing
|
||||
│ ├── converter.py
|
||||
│ ├── sse_manager.py
|
||||
│ └── formatter.py
|
||||
├── templates/ ⬅️ HTML separato per pagina
|
||||
├── static/ ⬅️ CSS, JS, immagini
|
||||
│ ├── css/
|
||||
│ ├── js/
|
||||
│ └── lib/
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Flask App │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Convert │ │ Reader │ │ API │ │
|
||||
│ │ /upload │ │ /reader │ │ /api/* │ │
|
||||
│ │ /progress│ │ /reader/ │ │ │ │
|
||||
│ │ /download│ │ <file> │ │ bookmarks│ │
|
||||
│ └─────┬────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌─────▼─────────────▼─────────────▼──────┐ │
|
||||
│ │ Services │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │converter │ │ database │─▶ SQLite │ │
|
||||
│ │ │sse_mgr │ └──────────┘ │ │
|
||||
│ │ │formatter │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Filesystem: uploads/ converted/ bookish.db │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
La separazione è chiara. Unica nota: `converter.py` importa `formatter.py` — dipendenza circolare potenziale ma gestita con import differito.
|
||||
## 8. Conclusioni
|
||||
|
||||
## 5. Possibili Miglioramenti
|
||||
**Bookish è un'applicazione solida per il suo scopo**: self-hosted, single-user, conversione e lettura ebook.
|
||||
|
||||
### Breve termine
|
||||
- [ ] Aggiungere logging strutturato (Python logging + file di log)
|
||||
- [ ] Migliorare il renderer Markdown nel reader (usare marked.js via CDN)
|
||||
- [ ] Aggiungere favicon diversa per stato conversione
|
||||
- [ ] Test di base con pytest
|
||||
### Cosa funziona bene
|
||||
- Il flusso conversione con SSE progress è semplice ma efficace
|
||||
- Il reader supporta EPUB, PDF e formati testo in un'unica interfaccia
|
||||
- La persistenza segnalibri con content_hash è robusta: funziona anche su PC diversi
|
||||
- Il fallback localStorage assicura che il reader non si rompa se il server API è giù
|
||||
- Slider larghezza e help modal completano l'UX del reader
|
||||
- La documentazione è completa e in italiano
|
||||
|
||||
### Medio termine
|
||||
- [ ] Supporto per code block syntax highlighting nel reader
|
||||
- [ ] Drag&drop anche nella pagina reader per caricamento diretto
|
||||
- [ ] Modalità notte persistente con preferenza sistema (prefers-color-scheme)
|
||||
- [ ] Aggiungere pipeline di CI/CD per test
|
||||
|
||||
### Lungo termine
|
||||
- [ ] Supporto multi-utente con autenticazione
|
||||
- [ ] Libreria personale con cronologia conversioni (SQLite)
|
||||
- [ ] Plugin system per engine di conversione custom
|
||||
- [ ] Mobile app via PWA
|
||||
|
||||
## 6. Conclusioni
|
||||
|
||||
Bookish è un'applicazione solida per il suo scopo. L'architettura è pulita, la separazione delle responsabilità è corretta, e le scelte tecnologiche sono appropriate per un progetto self-hostato a uso singolo. Le aree di miglioramento principali sono logging, testing e completezza del reader markdown.
|
||||
### Cosa sarebbe migliorabile (ma non necessario per single-user)
|
||||
- Suite di test automatici
|
||||
- Pipeline CI/CD
|
||||
- HTTPS nativo (gestito da reverse proxy in produzione)
|
||||
- Gestione code block più sofisticata nel reader testo
|
||||
- Mobile optimization
|
||||
- Logging strutturato (può essere aggiunto con poche righe)
|
||||
+108
-34
@@ -2,22 +2,23 @@
|
||||
|
||||
## Panoramica
|
||||
|
||||
Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostato. Si compone di due macro-funzionalità: un **convertitore di formati ebook** e un **reader web** per visualizzarli.
|
||||
Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostato. Si compone di due macro-funzionalità: un **convertitore di formati ebook** e un **reader web** per visualizzarli, con **SQLite** per persistenza di segnalibri e posizioni di lettura.
|
||||
|
||||
```
|
||||
┌──────────┐ ┌─────────────────────────────────────┐
|
||||
│ Browser │────▶│ Flask │
|
||||
│ │ │ ┌─────────┐ ┌──────────────────┐ │
|
||||
│ HTML/CSS │ │ │ Routes │ │ Services │ │
|
||||
│ JS/ES │ │ │ │ │ │ │
|
||||
│ │ │ │convert │─▶│converter.py │──▶│ Pandoc
|
||||
│epub.js │ │ │reader │ │sse_manager.py │ │ WeasyPrint
|
||||
│pdf.js │ │ │ │ │formatter.py │ │ python-docx
|
||||
└──────────┘ │ └─────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
│ │
|
||||
uploads/ converted/
|
||||
(temporaneo) (temporaneo)
|
||||
┌──────────┐ ┌──────────────────────────────────────────────┐
|
||||
│ Browser │────▶│ Flask │
|
||||
│ │ │ ┌──────────┐ ┌────────────────────────┐ │
|
||||
│ HTML/CSS │ │ │ Routes │ │ Services │ │
|
||||
│ JS/ES │ │ │ │ │ │ │
|
||||
│ │ │ │ convert │─▶│ converter.py │───▶│ Pandoc
|
||||
│ epub.js │ │ │ reader │ │ sse_manager.py │ │ WeasyPrint
|
||||
│ pdf.js │ │ │ api │ │ formatter.py │ │ python-docx
|
||||
└──────────┘ │ └──────────┘ │ database.py ──────────▶│───▶│ SQLite
|
||||
│ └────────────────────────┘ │
|
||||
│ │ │ │
|
||||
└──────────────────────────────────────────────┘
|
||||
uploads/ converted/
|
||||
(temporaneo) (temporaneo)
|
||||
```
|
||||
|
||||
## Stack Tecnologico
|
||||
@@ -25,6 +26,7 @@ Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostat
|
||||
| Componente | Tecnologia | Ruolo |
|
||||
|---|---|---|
|
||||
| Backend | Flask 3.x + Jinja2 | Server web, routing, template engine |
|
||||
| Database | SQLite (via sqlite3) | Segnalibri e posizioni di lettura |
|
||||
| Conversione | Pandoc 3.x (via pypandoc) | Motore di conversione principale |
|
||||
| PDF avanzato | WeasyPrint | PDF con layout precisi da HTML |
|
||||
| DOCX avanzato | python-docx | Post-processing documenti Word |
|
||||
@@ -32,7 +34,7 @@ Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostat
|
||||
| Reader EPUB | epub.js 0.3 | Renderer EPUB lato client |
|
||||
| Reader PDF | pdf.js 3.x | Renderer PDF lato client |
|
||||
| Progresso | SSE (Server-Sent Events) | Barra progresso real-time |
|
||||
| Storage | Filesystem locale | `uploads/` e `converted/` |
|
||||
| Storage | Filesystem + SQLite | `uploads/`, `converted/`, `bookish.db` |
|
||||
|
||||
## Flusso Conversione
|
||||
|
||||
@@ -58,41 +60,110 @@ Upload file → Validazione formato → Avvio thread conversione
|
||||
SSE invia download_url
|
||||
```
|
||||
|
||||
## Flusso Reader
|
||||
## Flusso Reader con Persistenza
|
||||
|
||||
```
|
||||
Upload file → Salva in uploads/ → Redirect a /reader/<file_id>
|
||||
│
|
||||
Template reader.html
|
||||
│
|
||||
Rileva formato:
|
||||
├─ EPUB → epub.js
|
||||
├─ PDF → pdf.js
|
||||
└─ Testo → HTML renderizzato
|
||||
│
|
||||
Interazioni tutte lato client:
|
||||
- Temi, zoom, ricerca
|
||||
- Segnalibri, posizione
|
||||
- Salvataggio in LocalStorage
|
||||
Upload file → Salva in uploads/ → Calcola SHA256 (content_hash)
|
||||
│
|
||||
Redirect a /reader/<file_id>
|
||||
│
|
||||
Template reader.html (contiene BOOKISH_HASH)
|
||||
│
|
||||
Rileva formato:
|
||||
├─ EPUB → epub.js
|
||||
├─ PDF → pdf.js
|
||||
└─ Testo → HTML renderizzato
|
||||
│
|
||||
Interazioni lato client:
|
||||
- Temi, zoom, ricerca, indice
|
||||
- Slider larghezza lettura
|
||||
- Aiuto contestuale (?)
|
||||
│
|
||||
Segnalibri e posizione:
|
||||
├─ Salvataggio immediato in localStorage
|
||||
├─ Sync asincrono su server SQLite
|
||||
└─ Caricamento da server con fallback localStorage
|
||||
```
|
||||
|
||||
Il **content_hash** (SHA256 del file) è l'identificatore univoco del libro. Viene calcolato:
|
||||
- All'upload (restituito nella response)
|
||||
- All'apertura del reader (dal file su disco)
|
||||
- Garantisce che lo stesso contenuto → stesso hash → stessi segnalibri
|
||||
|
||||
## Architettura del Codice
|
||||
|
||||
### Routes (`bookish/routes/`)
|
||||
|
||||
- **`convert.py`** — Blueprint `/upload`, `/progress/<task_id>`, `/download/<filename>`, `/formats`
|
||||
- **`reader.py`** — Blueprint `/reader`, `/reader/<filename>`, `/reader/serve/<filename>`, `/reader/formats`
|
||||
- **`reader.py`** — Blueprint `/reader`, `/reader/<filename>`, `/reader/serve/<filename>`, `/reader/formats`. Calcola SHA256 del file per identificare univocamente il libro.
|
||||
- **`api.py`** — Blueprint `/api/bookmarks/*` (CRUD segnalibri), `/api/position/*` (salva/carica posizione lettura)
|
||||
|
||||
### Services (`bookish/services/`)
|
||||
|
||||
- **`converter.py`** — Dispatcher che sceglie Pandoc o pipeline custom in base alla coppia input/output. Aggiorna SSE progress ad ogni fase.
|
||||
- **`sse_manager.py`** — Gestisce task in memoria con `{task_id: {progress, status, output_file}}`. Thread-safe con lock.
|
||||
- **`formatter.py`** — WeasyPrint PDF da HTML con post-processing (tabelle, immagini via BeautifulSoup).
|
||||
- **`database.py`** — Gestione SQLite con WAL mode. Due tabelle: `bookmarks` (per file) e `reading_positions` (una per file).
|
||||
|
||||
### Frontend (`bookish/static/js/`)
|
||||
|
||||
- **`main.js`** — Convertitore: drag&drop, form, SSE connection, download
|
||||
- **`reader.js`** — Reader: epub.js/pdf.js/text viewer, side panel, temi, ricerca, segnalibri, posizione
|
||||
- **`reader.js`** — Reader completo (800+ righe): epub.js/pdf.js/text viewer, sidebar con TOC/cerca/segnalibri, temi, zoom, slider larghezza, help modal, sync server per bookmarks e posizioni
|
||||
|
||||
### Template (`bookish/templates/`)
|
||||
|
||||
- **`base.html`** — Layout base con navbar e logo SVG
|
||||
- **`index.html`** — Convertitore con drag&drop, griglia formati, progress bar
|
||||
- **`reader.html`** — Reader con toolbar, sidebar, help modal, e script inline per BOOKISH_FILE, BOOKISH_EXT, BOOKISH_HASH
|
||||
|
||||
## API REST
|
||||
|
||||
| Metodo | Path | Descrizione |
|
||||
|--------|------|-------------|
|
||||
| POST | `/upload` | Carica file e avvia conversione |
|
||||
| GET | `/progress/<task_id>` | SSE stream progresso |
|
||||
| GET | `/download/<filename>` | Scarica file convertito |
|
||||
| GET | `/formats?input=<ext>` | Formati disponibili |
|
||||
| GET | `/reader` | Pagina reader |
|
||||
| GET | `/reader/<filename>` | Reader per file |
|
||||
| GET | `/reader/serve/<filename>` | Serve file al reader |
|
||||
| GET | `/reader/formats` | Formati supportati |
|
||||
| GET | `/api/bookmarks/<hash>` | Segnalibri del libro |
|
||||
| POST | `/api/bookmarks` | Aggiunge segnalibro |
|
||||
| DELETE | `/api/bookmarks/<id>` | Elimina segnalibro |
|
||||
| GET | `/api/position/<hash>` | Posizione di lettura |
|
||||
| PUT | `/api/position/<hash>` | Salva posizione di lettura |
|
||||
|
||||
## Database SQLite (`bookish.db`)
|
||||
|
||||
### Tabella `bookmarks`
|
||||
|
||||
| Colonna | Tipo | Descrizione |
|
||||
|---------|------|-------------|
|
||||
| id | TEXT PK | ID univoco del segnalibro |
|
||||
| content_hash | TEXT | SHA256 del file libro |
|
||||
| type | TEXT | epub, pdf, text |
|
||||
| cfi | TEXT | CFI per EPUB |
|
||||
| href | TEXT | Href del capitolo EPUB |
|
||||
| page | INTEGER | Numero pagina PDF |
|
||||
| total_pages | INTEGER | Pagine totali (PDF) |
|
||||
| scroll_pct | INTEGER | % scorrimento (testo) |
|
||||
| label | TEXT | Etichetta visualizzata |
|
||||
| chapter_title | TEXT | Titolo capitolo corrente |
|
||||
| created | REAL | Timestamp creazione |
|
||||
|
||||
### Tabella `reading_positions`
|
||||
|
||||
| Colonna | Tipo | Descrizione |
|
||||
|---------|------|-------------|
|
||||
| content_hash | TEXT PK | SHA256 del file libro |
|
||||
| type | TEXT | epub, pdf, text |
|
||||
| cfi | TEXT | CFI per EPUB |
|
||||
| href | TEXT | Href del capitolo |
|
||||
| page | INTEGER | Pagina PDF |
|
||||
| scroll_pct | INTEGER | % scorrimento (testo) |
|
||||
| chapter_title | TEXT | Titolo capitolo |
|
||||
| updated | REAL | Timestamp ultimo aggiornamento |
|
||||
|
||||
## Decisioni Architetturali
|
||||
|
||||
@@ -100,8 +171,11 @@ Upload file → Salva in uploads/ → Redirect a /reader/<file_id>
|
||||
|---|---|
|
||||
| Thread invece di Celery | Uso singolo utente, niente Redis, minore complessità |
|
||||
| SSE invece di WebSocket | Sincrono semplice, nessuna dipendenza extra, supporto nativo |
|
||||
| LocalStorage per reader | Zero backend, nessun DB necessario per lettura |
|
||||
| File temporanei con cleanup | Nessun database, i file si auto-puliscono dopo 1h |
|
||||
| content_hash (SHA256) invece di UUID | Identifica il contenuto, non l'istanza. Stesso file = stesso hash |
|
||||
| SQLite per persistenza | Incluso in Python, zero configurazione, file singolo |
|
||||
| Server + localStorage come fallback | Offline-resilient, UX istantanea, sync asincrono |
|
||||
| Position save debounced (1.5s) | Evita flooding di scritture SQLite ad ogni pagina |
|
||||
| File temporanei con cleanup | Nessun accumulo, auto-pulizia dopo 1h |
|
||||
| JS vanilla | Nessun framework, leggero, manutenibile |
|
||||
| Pandoc via pypandoc-binary | Include Pandoc binary, nessuna installazione systema richiesta |
|
||||
|
||||
@@ -111,4 +185,4 @@ Upload file → Salva in uploads/ → Redirect a /reader/<file_id>
|
||||
- **No autenticazione**: Uso personale, nessun multi-utente
|
||||
- **No HTTPS di base**: Da abilitare con reverse proxy (nginx/caddy)
|
||||
- **No ottimizzazione mobile**: Funziona ma non è prioritario
|
||||
- **estione code block reader**: Reader testo ha markdown renderer minimale
|
||||
- **Gestione code block reader**: Reader testo ha markdown renderer minimale
|
||||
+112
-57
@@ -1,81 +1,89 @@
|
||||
# Deploy Bookish
|
||||
# Deploy di Bookish
|
||||
|
||||
## Su Proxmox LXC (raccomandato)
|
||||
|
||||
### 1. Crea container LXC
|
||||
|
||||
```bash
|
||||
pct create 120 local:vault/almalinux-9-default_20240918_amd64.tar.xz \
|
||||
--storage local-lvm \
|
||||
--memory 1024 \
|
||||
--swap 512 \
|
||||
--cores 2 \
|
||||
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
|
||||
--ostype almalinux \
|
||||
--unprivileged 1
|
||||
pct start 120
|
||||
pct enter 120
|
||||
```
|
||||
|
||||
### 2. Installa dipendenze
|
||||
|
||||
```bash
|
||||
# Python e strumenti di sviluppo
|
||||
dnf install -y python3 python3-pip git python3-devel gcc \
|
||||
pango-devel cairo-devel gdk-pixbuf2-devel
|
||||
|
||||
# Per WeasyPrint
|
||||
dnf install -y libffi-devel redhat-rpm-config
|
||||
```
|
||||
|
||||
### 3. Clona e setup
|
||||
## Preparazione
|
||||
|
||||
```bash
|
||||
# Clona il repository
|
||||
git clone http://192.168.0.183:8085/truetype/bookish.git
|
||||
cd bookish
|
||||
|
||||
# Ambiente virtuale
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Verifica
|
||||
python app.py
|
||||
# → Avvia su 0.0.0.0:5000 (o porta libera successiva)
|
||||
# → Crea automaticamente bookish.db
|
||||
```
|
||||
|
||||
## Opzioni di Deploy
|
||||
|
||||
### 1. Proxmox LXC (consigliato)
|
||||
|
||||
Crea un container Linux privilegiato (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
pct create 100 local/vault/debian-12-standard_12.7-1_amd64.tar.zst \
|
||||
--hostname bookish \
|
||||
--storage local-zfs \
|
||||
--rootfs 4 \
|
||||
--cores 1 \
|
||||
--memory 512 \
|
||||
--net0 name=eth0,bridge=vmbr0,firewall=1,ip=dhcp
|
||||
```
|
||||
|
||||
All'interno del container:
|
||||
|
||||
```bash
|
||||
apt update && apt install -y python3 python3-venv python3-pip git
|
||||
git clone http://192.168.0.183:8085/truetype/bookish.git /opt/bookish
|
||||
cd /opt/bookish
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Crea service systemd
|
||||
### 2. Docker (consigliato in questo fork)
|
||||
|
||||
Usa i file `Dockerfile` e `docker-compose.yml` in root. Non montare un volume su `/app`: cancellerebbe il codice.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# imposta BOOKISH_SECRET
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Volume unico: `bookish-data` → `/data` (uploads, converted, `bookish.db`).
|
||||
|
||||
Gunicorn ascolta su `0.0.0.0:5000` con 1 worker. Healthcheck: `GET /healthz`.
|
||||
|
||||
### 3. Systemd Service
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/bookish.service
|
||||
[Unit]
|
||||
Description=Bookish Ebook Converter & Reader
|
||||
Description=Bookish Flask App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
User=bookish
|
||||
WorkingDirectory=/opt/bookish
|
||||
ExecStart=/opt/bookish/.venv/bin/python /opt/bookish/app.py 5000
|
||||
Environment=PATH=/opt/bookish/.venv/bin
|
||||
Environment=BOOKISH_SECRET=your-secure-secret-key
|
||||
ExecStart=/opt/bookish/.venv/bin/python app.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=BOOKISH_SECRET=your-secret-here
|
||||
Environment=BOOKISH_DEBUG=false
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now bookish
|
||||
```
|
||||
|
||||
## Docker (alternativa)
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
RUN apt-get update && apt-get install -y pandoc libpango-1.0-0 \
|
||||
libcairo2 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
EXPOSE 5000
|
||||
CMD ["python", "app.py", "5000"]
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable bookish
|
||||
sudo systemctl start bookish
|
||||
```
|
||||
|
||||
## Reverse Proxy (Caddy)
|
||||
@@ -86,10 +94,57 @@ bookish.tuodominio.it {
|
||||
}
|
||||
```
|
||||
|
||||
Con Caddy l'HTTPS è automatico.
|
||||
Con Caddy ottieni automaticamente HTTPS tramite Let's Encrypt.
|
||||
|
||||
## Pulizia e Manutenzione
|
||||
## Dati persistenti
|
||||
|
||||
I file in `uploads/` e `converted/` vengono puliti automaticamente dopo 1 ora. Nessun DB da manutenere.
|
||||
Bookish usa il filesystem per dati temporanei e un database SQLite per dati permanenti:
|
||||
|
||||
Per backup basta copiare la directory del progetto (escludendo `uploads/`, `converted/`, `.venv/`).
|
||||
| Cosa | Dove | Persistente? |
|
||||
|---|---|---|
|
||||
| File caricati | `uploads/` | ❌ (pulizia 1h) |
|
||||
| File convertiti | `converted/` | ❌ (pulizia 1h) |
|
||||
| Segnalibri e posizioni | `bookish.db` | ✅ (SQLite) |
|
||||
|
||||
**Backup**: per salvare segnalibri e posizioni, copia `bookish.db`:
|
||||
|
||||
```bash
|
||||
cp /opt/bookish/bookish.db /tuo/backup/
|
||||
```
|
||||
|
||||
Il database si ricrea automaticamente se manca (perdendo però i dati precedenti).
|
||||
|
||||
## Configurazione Porta
|
||||
|
||||
Se `app.py` non trova la porta specificata, scorre in sequenza (`+1`) finché non trova una porta libera. Puoi specificare la porta di partenza come argomento:
|
||||
|
||||
```bash
|
||||
python app.py 8080
|
||||
# Prova 8080, 8081, 8082...
|
||||
```
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
| Variabile | Default | Descrizione |
|
||||
|---|---|---|
|
||||
| `BOOKISH_SECRET` | obbligatorio se debug=false | Flask secret key |
|
||||
| `BOOKISH_DATA_DIR` | directory progetto / `/data` in Docker | Upload, convertiti, SQLite |
|
||||
| `BOOKISH_DEBUG` | `false` | Modalità debug (`true`/`false`) |
|
||||
| `BOOKISH_CLEANUP_HOURS` | `1` | Ore prima di cancellare i file temporanei |
|
||||
| `PORT` | 5000 | Porta gunicorn / mapping compose |
|
||||
|
||||
## Manutenzione
|
||||
|
||||
```bash
|
||||
# Update app
|
||||
git pull
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt --upgrade
|
||||
systemctl restart bookish # se systemd
|
||||
|
||||
# Pulizia hard disk
|
||||
rm -rf uploads/* converted/*
|
||||
|
||||
# Backup
|
||||
cp bookish.db bookish.db.$(date +%Y%m%d).bak
|
||||
```
|
||||
@@ -0,0 +1,272 @@
|
||||
# Revisione Docker 2026 — Bookish (fork Docker)
|
||||
|
||||
Revisione effettuata il 29/08/2026 su `main` (HEAD `e62c50c`).
|
||||
Scopo: verificare che il progetto sia **completo, sicuro e manutenibile come
|
||||
Docker app**, e definire un piano di miglioramento priorizzabile.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sintesi esecutiva
|
||||
|
||||
Il progetto è già una Docker app **funzionante** e ben impacchettata:
|
||||
|
||||
- Dockerfile multistage + utente non-root + healthcheck + compose con limiti
|
||||
- Conversione asincrona con SSE, storage persistente su volume
|
||||
- Configurazione 12-factor via variabili d'ambiente
|
||||
|
||||
La revisione ha però evidenziato **1 problema funzionale critico**, **3 falle di
|
||||
sicurezza** e una serie di **carenze strutturali** (test, lock, deploy) che
|
||||
vanno chiuse prima di considerare il progetto "pronto" per un uso reale.
|
||||
|
||||
Esito revisione: **❌ non pronta per produzione — servono correzioni**.
|
||||
|
||||
> Aggiornamento (sessione corrente): la P0 è chiusa (**R1, R2, R3, R6**) e
|
||||
> parte della P1 (**R4** lock + **CI** smoke test + **self-host JS offline** +
|
||||
> **R4b** checksum lock nel Dockerfile/CI).
|
||||
> Restano P2 (R5, R7).
|
||||
|
||||
---
|
||||
|
||||
## 2. Punti di forza (cosa tenere)
|
||||
|
||||
1. **Dockerfile a 2 stadi** — builder con venv separato, runtime minimale
|
||||
(pandoc, pango, weasyprint deps), `--no-install-recommends` e cancellazione
|
||||
apt-lists.
|
||||
|
||||
2. **Compose completo** — `no-new-privileges`, `tmpfs /tmp`, `mem_limit`,
|
||||
`cpus`, `restart: unless-stopped`, `stop_grace_period`, `PORT` parametrizzata.
|
||||
|
||||
3. **Healthcheck** `/healthz` che verifica davvero la scrivibilità del volume
|
||||
dati.
|
||||
|
||||
4. **Non-root + uid deterministico 1000** — volume riutilizzabile senza fix
|
||||
di permessi.
|
||||
|
||||
5. **Conversione asincrona** via thread + SSE con lock thread-safe e cleanup
|
||||
automatico anche senza cliente connesso (`generate_sse_events` con finally).
|
||||
|
||||
6. **Fallback localStorage** lato reader: l'app resta utilizzabile se il
|
||||
server API non risponde.
|
||||
|
||||
7. **SQLite WAL + parametrized query**: robustness accettabile per uso
|
||||
single-user.
|
||||
|
||||
8. **Documentazione** (README, docs/) completa e in italiano.
|
||||
|
||||
---
|
||||
|
||||
## 3. Debolezze — Analisi mirata
|
||||
|
||||
### 3.1 🔴 [CRITICO] IL Reader fallisce per una quota di file convertiti
|
||||
|
||||
Nel modalità Docker i file vengono salvati con prefisso `uuid_`:
|
||||
|
||||
```
|
||||
UPLOAD_FOLDER → /data/uploads/<uuid>_<safe_name> (con prefisso uuid)
|
||||
CONVERTED_FOLDER → /data/converted/<uuid>.<ext> (nome = solo uuid)
|
||||
```
|
||||
|
||||
Il problema: `bookish/routes/reader.py` cerca i file **per nome**
|
||||
(`UPLOAD_FOLDER/<filename>`, poi `CONVERTED_FOLDER/<filename>`),
|
||||
mentre il flusso reale usa:
|
||||
|
||||
- upload: `<uuid>_<filename>` — corretto, perché `stored_filename` viene
|
||||
passato dalla UI (funziona già).
|
||||
- convertito: `<uuid>.ext` — il reader NON cerca mai `<uuid>.ext` con il
|
||||
nome completo generato;
|
||||
cerca il `<filename>` originale, che il file convertito non ha.
|
||||
|
||||
Quindi:
|
||||
|
||||
- il link "Leggi nel reader" dalla UI dei **convertiti funziona** (il JS
|
||||
passa `stored_filename`);
|
||||
- ma i **link diretti `/reader/<nome_originale>`** (es. da localStorage,
|
||||
`recenti`, o bookmark a mano) restituiscono **404** per il converted
|
||||
(il reader non risolve `<uuid>` né l'originale).
|
||||
|
||||
**Fix proposto** (P0, già coperto da test `tests/test_storage_paths.py`):
|
||||
|
||||
1. Nel reader, risolvere il file per il suo **stored id** applicando le due
|
||||
regole insieme:
|
||||
- cercare prima `UPLOAD_FOLDER/<filename>` (caso upload, già ok),
|
||||
- poi `CONVERTED_FOLDER/<filename>` (caso convertito — **aggiungere**
|
||||
la ricerca diretta per il nome generato `<uuid>.ext`);
|
||||
2. In più cercare per suffisso per non rompere i bare-name (fallback).
|
||||
3. Mantenere `os.path.basename()` e la whitelist estensioni.
|
||||
|
||||
**Stato (R1)**: ✅ fatto — il reader ora risolve i file convertiti per ID
|
||||
(compresi i link diretti `recent`/localStorage con bare name).
|
||||
|
||||
### 3.2 🟠 [Security] Nessuna autenticazione → disk DoS
|
||||
|
||||
**Stato (R2)**: ✅ fatto in `bookish/services/security.py` — auth Basic
|
||||
opzionale (`BOOKISH_AUTH_USERNAME`/`BOOKISH_AUTH_PASSWORD`) con
|
||||
`WWW-Authenticate` su tutte le route tranne healthz/static + rate-limit
|
||||
in-memory su POST /upload (`BOOKISH_RATE_LIMIT_UPLOAD`, default 20/ora,
|
||||
0=off).
|
||||
|
||||
Note:
|
||||
- Basic su HTTP non cifra le credenziali → abilitare dietro proxy HTTPS.
|
||||
- Rate limit in-memory (singolo worker) → ok per uso personale; per difese
|
||||
robuste aggiungere limite a livello proxy.
|
||||
|
||||
### 3.3 🔴 [Sicurezza] XSS nel reader HTML
|
||||
|
||||
`bookish/static/js/reader.js:165`:
|
||||
|
||||
```js
|
||||
viewer.innerHTML = text; // file HTML grezzo iniettato nel DOM
|
||||
```
|
||||
|
||||
Un file HTML generato da Pandoc/WeasyPrint (o un file con HTML sorgente)
|
||||
può eseguire script nel dominio delle API. Certo è "self-hosted file non
|
||||
affidabili", ma un libro malevolo può esfiltrare bookmarks/cookies.
|
||||
|
||||
Mitigazioni:
|
||||
|
||||
1. **CSP restrittiva**: header `Content-Security-Policy` con
|
||||
`default-src 'self'` e allowlist CDN per epub.js/pdf.js/jszip.
|
||||
I contenuti iniettati non possono eseguire `script-src` di terze parti.
|
||||
2. Sanitizzazione HTML (es. `DOMPurify` client-side, o `bleach` server-side)
|
||||
del contenuto iniettato.
|
||||
3. **Sandbox iframe**: rendere il contenutO in `<iframe sandbox>` senza
|
||||
script — fa perdere funzionalità ma è il fix più robusto.
|
||||
|
||||
**Stato (R3)**: ✅ fatto —
|
||||
1. CSP applicata a tutte le risposte via middleware (`object-src 'none'`,
|
||||
`base-uri 'self'`, `form-action 'self'`, allowlist CDN) + headers
|
||||
`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
|
||||
`Referrer-Policy: no-referrer`.
|
||||
2. Sanitizzazione lato client dell'HTML iniettato nel reader con
|
||||
**DOMPurify self-hosted** (`static/vendor/purify.min.js`, v3.1.6).
|
||||
3. MD/TXT/FB2/DOCX passano già da `escapeHtml` (sicuri).
|
||||
|
||||
### 3.4 🟠 [Sicurezza] Secret di default + no lockfile
|
||||
|
||||
- `SECRET_KEY` di default `"cambiami-in-produzione"` accettata in DEBUG. In
|
||||
Docker `DEBUG=false` quindi il guard funZiona. OK ma `BOOKISH_SECRET`
|
||||
shorthands non validate (lunghezza/entropia).
|
||||
- `requirements.txt` pinned "alla cieca": ness un lockfile riproducibile per
|
||||
il layer pip del Docker builder → build non-reproducible; aggiornamenti di
|
||||
patch non tracciati.
|
||||
|
||||
**Fix**: `constraints.txt` o lockfile (`pip freeze` nelle release) e archiviare
|
||||
il lock nel repo; oppure `pip-tools`. Verificare sha/checksum opzionale.
|
||||
|
||||
### 3.5 🟠 [Stabilità] `tmpfs /tmp` e conversioni pesanti
|
||||
|
||||
WeasyPrint/`pandoc` in /tmp: `tmpfs` piccola di default (è `/tmp` su `/`).
|
||||
Il compose monta `tmpfs: - /tmp` → i file temporanei di conversione
|
||||
vengono scritti in RAM → rischio `No space left`/OOM con PDF grandi.
|
||||
|
||||
**Valutazione**: con `mem_limit: 1g` e PDF fino a 200 pagine è marginale,
|
||||
ma è un limite implicito nondocumentato.
|
||||
|
||||
**Fix proposto**: documentare, oppure usare il volume dati per tmp
|
||||
(`/data/tmp`), oppure aumentare `--shm-size`.
|
||||
|
||||
### 3.6 🟡 [Build] Immagine senza lock e senza test
|
||||
|
||||
- Nessun `pytest`/test nel repo → regressioni possibili.
|
||||
- Il build Docker non esegue smoke test automatici dell'app.
|
||||
|
||||
### 3.7 🟡 [Maintenability] `requirements.txt` include solo run e builder
|
||||
|
||||
Il builder copia `requirements.txt` e installa tutto — incluso WeasyPrint
|
||||
che il runtime usa. Va bene, ma la riga
|
||||
`pypandoc==1.15` polarizza la ricerca del formato… `pypandoc.get_pandoc_formats()`
|
||||
richiede il binary `pandoc` in PATH — nel container è installato via apt ✔.
|
||||
|
||||
### 3.8 🟡 [Ops] Gunicorn
|
||||
|
||||
- `threads=8` con `workers=1` + SSE in-memory → OK per single-user, ma se il
|
||||
worker muore, i task in corso si perdono (documentato).
|
||||
- `timeout=300` (5 min): le conversioni WeasyPrint di libri lunghi possono
|
||||
superarlo → il worker viene killato senza errore chiaro.
|
||||
- `proxy_allow_from = "*"` era un'opzione **inesistente** (gunicorn la
|
||||
ignora silenziosamente senza errori) → rimossa.
|
||||
- `forwarded_allow_ips = "*"` dava fiducia a qualsiasi `X-Forwarded-For`
|
||||
da internet → ora in configurabile via `FORWARDED_ALLOW_IPS` (default
|
||||
`127.0.0.1`) e **non più wildcard**.
|
||||
|
||||
**Stato (R6)**: ✅ risolto in `gunicorn.conf.py` — opzioni inesistenti
|
||||
rimosse, trust proxy ristretto via env.
|
||||
|
||||
### 3.9 🟡 [Observability]
|
||||
|
||||
- Unico log: gunicorn `accesslog/errorlog "-"` and `app.logger` non
|
||||
configurate (DEBUG/INFO default). Nessun structured logging, nessuna
|
||||
metrica (prometheus/flask-exporter assente).
|
||||
|
||||
---
|
||||
|
||||
## 4. Punti di debolezza perchè lo chiedi: read critica
|
||||
|
||||
### 4.1 Testing
|
||||
- **Zero test** automatici. `bookish/` ha import di prova sboccata da noi
|
||||
dopo — significa **nessun gate** per le correzioni Docker.
|
||||
|
||||
### 4.2 UI
|
||||
- Reader self-hosted: epub.js/pdf.js+worker/jszip sono in `static/vendor/`
|
||||
(no CDN, funziona offline). ✅ fatto — resta il tema CSP `'unsafe-inline'`
|
||||
per lo script inline del template (marginale).
|
||||
|
||||
### 4.3 Multi-user
|
||||
- `workers=1` by design per gli SSE: **no scalabilità orizzontale**, no
|
||||
multiutente. OK per lo scope dichiarato — ma la doc lo dice?
|
||||
|
||||
### 4.4 Storage
|
||||
- Upload e converted separati (uploads/ + converted/) → backup e reasoning
|
||||
più complicati. Un solo `data/files` sarebbe più semplice.
|
||||
|
||||
---
|
||||
|
||||
## 5. Matrice dei rischi
|
||||
|
||||
| # | Rischio | Gravità | Probabilità | Stato |
|
||||
|---|---------|---------|-------------|-------|
|
||||
| R1 | Reader 404 su file convertiti | Alta | Alta | ✅ risolto |
|
||||
| R2 | Disk/CPU DoS via upload non autenticato | Alta | Media | ✅ mitigato (auth+rate) |
|
||||
| R3 | XSS da file HTML malevoli | Alta | Bassa | ✅ mitigato (CSP+DOMPurify) |
|
||||
| R4 | Build non riproducibile | Media | Media | ✅ lockfile + checksum pinned |
|
||||
| R5 | Errori timeout conversioni lunghe | Media | Media | ⏳ P2 |
|
||||
| R6 | Trust proxy / opzioni fantasma | Bassa | Media | ✅ risolto |
|
||||
| R7 | /tmp tmpfs vs conversioni grandi | Bassa | Bassa | ⏳ P2 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Raccomandazioni prioritizzate (Roadmap)
|
||||
|
||||
### P0 — Critico (prima di prod)
|
||||
- [x] **R1** Fix naming/storage nel reader (file convertiti + upload con
|
||||
prefisso uuid). Aggiungere test. ✅ fatto
|
||||
- [x] **R2** Autentica/rate-limit su `/upload` (auth Basic + rate limit). ✅ fatto
|
||||
- [x] **R3** CSP + sanitizzazione in reader (CSP headers + DOMPurify). ✅ fatto
|
||||
|
||||
### P1 — Alta
|
||||
- [x] **R4** Lockfile/constraints per dipendenze Python.
|
||||
✅ `requirements.lock` (pip-compile, 112 righe) + Dockerfile lo usa.
|
||||
- [x] Self-hosting dei JS frontend (per offline).
|
||||
✅ epub.js/pdf.js+worker/JSZip vendored in `static/vendor/`, CSP senza CDN.
|
||||
- [x] **R4b** Hash del lockfile pinnato nel Dockerfile (build-arg
|
||||
`REQUIREMENTS_CHECKSUM`, default in compose) + job CI di verifica
|
||||
(`.github/workflows/checksum.yml`). ✅ fatto
|
||||
- [x] **R6** Cleanup gunicorn.conf (opzioni inesistenti; `forwarded_allow_ips`
|
||||
solo per proxy fidato noto). ✅ già fatto
|
||||
- [x] Smoke test Docker nella CI (`docker build` + `docker compose up` +
|
||||
healthcheck + upload/conversion test). ✅ `.github/workflows/ci.yml`
|
||||
|
||||
### P2 — Manutenibilità
|
||||
- [ ] **R5** Retry/status per conversioni lunghe (job esterno opzionale —
|
||||
out of scope per ora, ma documentare).
|
||||
- [ ] **R7** Logging strutturato + metrica basilica (`/metrics` semplice o
|
||||
prometheus exporter).
|
||||
- [x] Self-hosting dei JS frontend (per offline). ✅ fatto — vedi §3.2/5.
|
||||
- [ ] Valutare volume unico `/data/files` (uploads+converted) per semplificare
|
||||
backup e cleanup docs.
|
||||
|
||||
### P3 — Stretch (solo se serve)
|
||||
- [ ] Multi-utente/autenticazione reale.
|
||||
- [ ] Task queue esterna (Celery/Redis/Dramatiq) per portare SSE e conversione
|
||||
fuori dal gunicorn worker.
|
||||
- [ ] Pagination e scansione `recent` server-side nel reader.
|
||||
+63
-13
@@ -25,20 +25,54 @@
|
||||
| FB2 | EPUB, MD, DOCX, PDF, TXT, HTML, LaTeX |
|
||||
| LaTeX | EPUB, MD, DOCX, PDF, TXT, HTML |
|
||||
|
||||
### Reader
|
||||
### Reader — Operazioni base
|
||||
|
||||
1. Vai su **Reader** nella navigazione
|
||||
2. Carica un ebook (EPUB, PDF, MD, TXT, HTML, FB2, DOCX)
|
||||
2. Trascina un ebook (EPUB, PDF, MD, TXT, HTML, FB2, DOCX) o clicca per caricarlo
|
||||
3. Usa la toolbar per:
|
||||
- **☰** — Apri/chiudi indice
|
||||
- **A− / A+** — Zoom font
|
||||
- **☀️ / 🌙 / 🟤** — Cambia tema (chiaro/scuro/seppia)
|
||||
- **🔍** — Cerca nel testo
|
||||
- **⛶** — Schermo intero
|
||||
4. Naviga con frecce tastiera ← → o cliccando ai lati
|
||||
5. La posizione di lettura viene salvata automaticamente
|
||||
|
||||
### API Endpoints
|
||||
| Pulsante | Azione |
|
||||
|---|---|
|
||||
| **☰** | Apri/chiudi indice laterale |
|
||||
| **⇔** | Slider larghezza area di lettura (40–100%) |
|
||||
| **A− / A+** | Zoom font |
|
||||
| **☀️ / 🌙 / 🟤** | Cambia tema (chiaro/scuro/seppia) |
|
||||
| **🔍** | Cerca nel testo |
|
||||
| **🔖** | Aggiungi segnalibro alla posizione corrente |
|
||||
| **⛶** | Schermo intero |
|
||||
| **?** | Aiuto e scorciatoie da tastiera |
|
||||
|
||||
4. Naviga con le frecce **← →** della tastiera
|
||||
5. La **posizione di lettura** viene salvata automaticamente (locale + server)
|
||||
|
||||
### Reader — Segnalibri
|
||||
|
||||
- Premi **🔖** nella toolbar o **Ctrl+B** / **⌘B** per aggiungere un segnalibro
|
||||
- Un toast di conferma appare in basso e il pulsante fa un piccolo "pulse"
|
||||
- Vai nella sidebar **☰ → Segnalibri** per vedere l'elenco
|
||||
- **Clicca** su un segnalibro per tornarci
|
||||
- Premi **✕** per eliminarlo
|
||||
- I segnalibri sono salvati su server SQLite + localStorage (fallback)
|
||||
- Lo stesso libro ha sempre gli stessi segnalibri, anche su PC diverso
|
||||
|
||||
### Reader — Scorciatoie da tastiera
|
||||
|
||||
| Tasti | Azione |
|
||||
|---|---|
|
||||
| **←** / **→** | Pagina precedente/successiva |
|
||||
| **Ctrl+B** / **⌘B** | Aggiungi segnalibro |
|
||||
| **Ctrl+F** / **⌘F** | Cerca nel libro |
|
||||
| **+** / **-** | Zoom avanti/indietro |
|
||||
| **F** | Schermo intero |
|
||||
| **Esc** | Chiudi help modal / Esci da fullscreen |
|
||||
|
||||
### Reader — Slider larghezza
|
||||
|
||||
La barra a destra nella toolbar (con icona ⇔) regola la larghezza dell'area di lettura dal 40% al 100% dello schermo. Utile per file con righe molto lunghe o per preferenze personali di lettura. L'impostazione viene salvata in localStorage.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Endpoints pubblici
|
||||
|
||||
| Metodo | Path | Descrizione |
|
||||
|---|---|---|
|
||||
@@ -51,16 +85,32 @@
|
||||
| GET | `/reader/serve/<filename>` | Serve il file al reader |
|
||||
| GET | `/reader/formats` | Formati supportati dal reader |
|
||||
|
||||
### API REST (bookmarks e posizioni)
|
||||
|
||||
| Metodo | Path | Descrizione |
|
||||
|---|---|---|
|
||||
| GET | `/api/bookmarks/<content_hash>` | Recupera tutti i segnalibri del libro |
|
||||
| POST | `/api/bookmarks` | Aggiunge un segnalibro |
|
||||
| DELETE | `/api/bookmarks/<id>` | Elimina un segnalibro |
|
||||
| GET | `/api/position/<content_hash>` | Recupera l'ultima posizione di lettura |
|
||||
| PUT | `/api/position/<content_hash>` | Salva la posizione di lettura (body JSON) |
|
||||
|
||||
## Domande frequenti
|
||||
|
||||
**D: Che qualità ha la conversione?**
|
||||
R: Pandoc preserva titoli, grassetto/corsivo, elenchi, tabelle semplici e immagini. Per layout complessi (es. PDF con colonne multiple) la fedeltà dipende dalla complessità del file originale.
|
||||
|
||||
**D: I file vengono conservati?**
|
||||
R: No, vengono cancellati dopo 1 ora. Usa il download subito dopo la conversione.
|
||||
R: I file in `uploads/` e `converted/` vengono cancellati dopo 1 ora. Usa il download subito dopo la conversione. I segnalibri e le posizioni di lettura sono invece permanenti in `bookish.db`.
|
||||
|
||||
**D: I segnalibri restano se ricarico lo stesso libro?**
|
||||
R: Sì. I segnalibri sono legati al contenuto del file (SHA256), non al nome o all'UUID di upload. Stesso file = stessi segnalibri.
|
||||
|
||||
**D: Posso usare i segnalibri da un altro PC?**
|
||||
R: Sì, se sei sulla stessa LAN e raggiungi lo stesso server. I segnalibri sono salvati su SQLite, non nel browser.
|
||||
|
||||
**D: Quanto spazio serve?**
|
||||
R: Minimo. L'app stessa è ~5MB. Pandoc binary incluso ~35MB extra.
|
||||
R: Minimo. L'app stessa è ~5MB. Pandoc binary incluso ~35MB extra. Il database SQLite occupa pochi KB.
|
||||
|
||||
**D: Posso usarlo su mobile?**
|
||||
R: Il convertitore è responsive. Il reader funziona ma non è ottimizzato per schermi piccoli.
|
||||
@@ -75,4 +125,4 @@ R: Imposta `BOOKISH_SECRET` come variabile d'ambiente prima di avviare.
|
||||
- Path traversal prevenuto con `os.path.basename()` e `secure_filename()`
|
||||
- MIME types controllati al download
|
||||
- Limite file a 50MB
|
||||
- Pulizia automatica file temporanei
|
||||
- Pulizia automatica file temporanei dopo 1h
|
||||
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
from config import CONVERSION_TIMEOUT_SECONDS
|
||||
|
||||
bind = f"0.0.0.0:{os.environ.get('PORT', '5000')}"
|
||||
# Un solo worker: i task SSE vivono in memoria del processo.
|
||||
workers = 1
|
||||
threads = 8
|
||||
worker_class = "gthread"
|
||||
# Timeout di conversione: default 300s, configurabile per conversione lunge
|
||||
# (WeasyPrint su libri lunghi può richiedere di più).
|
||||
timeout = CONVERSION_TIMEOUT_SECONDS
|
||||
graceful_timeout = 30
|
||||
keepalive = 5
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
loglevel = os.environ.get("LOG_LEVEL", "info")
|
||||
|
||||
# Serve solo se dietro un reverse proxy che imposta in modo affidabile
|
||||
# X-Forwarded-For. Di default restringiamo: solo localhost.
|
||||
# In Docker dietro Caddy/nginx, imposta FORWARDED_ALLOW_IPS alla lista dei
|
||||
# proxy (es. "127.0.0.1,172.17.0.1") per far rispettare il protocollo:
|
||||
# da `*` darebbe fiducia a qualsiasi X-Forwarded-For di internet.
|
||||
forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")
|
||||
@@ -0,0 +1,28 @@
|
||||
beautifulsoup4==4.15.0
|
||||
blinker==1.9.0
|
||||
brotli==1.2.0
|
||||
cffi==2.1.0
|
||||
click==8.4.2
|
||||
cssselect2==0.9.0
|
||||
Flask==3.1.3
|
||||
fonttools==4.63.0
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
lxml==6.1.1
|
||||
Markdown==3.10.2
|
||||
MarkupSafe==3.0.3
|
||||
pillow==12.3.0
|
||||
pycparser==3.0
|
||||
pydyf==0.12.1
|
||||
pypandoc==1.15
|
||||
gunicorn==23.0.0
|
||||
pyphen==0.17.2
|
||||
python-docx==1.2.0
|
||||
soupsieve==2.9.1
|
||||
tinycss2==1.5.1
|
||||
tinyhtml5==2.1.0
|
||||
typing_extensions==4.16.0
|
||||
weasyprint==69.0
|
||||
webencodings==0.5.1
|
||||
Werkzeug==3.1.8
|
||||
zopfli==0.4.3
|
||||
@@ -0,0 +1,112 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --no-emit-index-url --no-index --output-file=requirements.lock --strip-extras requirements.in
|
||||
#
|
||||
beautifulsoup4==4.15.0
|
||||
# via -r requirements.in
|
||||
blinker==1.9.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
brotli==1.2.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# fonttools
|
||||
cffi==2.1.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# weasyprint
|
||||
click==8.4.2
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
cssselect2==0.9.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# weasyprint
|
||||
flask==3.1.3
|
||||
# via -r requirements.in
|
||||
fonttools==4.63.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# weasyprint
|
||||
gunicorn==23.0.0
|
||||
# via -r requirements.in
|
||||
itsdangerous==2.2.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
jinja2==3.1.6
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
lxml==6.1.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# python-docx
|
||||
markdown==3.10.2
|
||||
# via -r requirements.in
|
||||
markupsafe==3.0.3
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
# jinja2
|
||||
# werkzeug
|
||||
packaging==26.3
|
||||
# via gunicorn
|
||||
pillow==12.3.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# weasyprint
|
||||
pycparser==3.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cffi
|
||||
pydyf==0.12.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# weasyprint
|
||||
pypandoc==1.15
|
||||
# via -r requirements.in
|
||||
pyphen==0.17.2
|
||||
# via
|
||||
# -r requirements.in
|
||||
# weasyprint
|
||||
python-docx==1.2.0
|
||||
# via -r requirements.in
|
||||
soupsieve==2.9.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# beautifulsoup4
|
||||
tinycss2==1.5.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cssselect2
|
||||
# weasyprint
|
||||
tinyhtml5==2.1.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# weasyprint
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# beautifulsoup4
|
||||
# python-docx
|
||||
weasyprint==69.0
|
||||
# via -r requirements.in
|
||||
webencodings==0.5.1
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cssselect2
|
||||
# tinycss2
|
||||
# tinyhtml5
|
||||
werkzeug==3.1.8
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
zopfli==0.4.3
|
||||
# via
|
||||
# -r requirements.in
|
||||
# fonttools
|
||||
+2
-1
@@ -14,7 +14,8 @@ MarkupSafe==3.0.3
|
||||
pillow==12.3.0
|
||||
pycparser==3.0
|
||||
pydyf==0.12.1
|
||||
pypandoc_binary==1.17
|
||||
pypandoc==1.15
|
||||
gunicorn==23.0.0
|
||||
pyphen==0.17.2
|
||||
python-docx==1.2.0
|
||||
soupsieve==2.9.1
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Avvia Bookish con virtualenv
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR" || exit 1
|
||||
.venv/bin/python app.py "${1:-5000}"
|
||||
|
||||
if [ ! -x .venv/bin/python ]; then
|
||||
echo "Crea il venv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"
|
||||
echo "Serve anche pandoc: sudo apt install pandoc"
|
||||
echo "Oppure: cp .env.example .env && docker compose up --build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v pandoc >/dev/null 2>&1; then
|
||||
echo "pandoc non trovato. Installa: sudo apt install pandoc"
|
||||
echo "Oppure usa Docker: docker compose up --build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export BOOKISH_DEBUG="${BOOKISH_DEBUG:-true}"
|
||||
exec .venv/bin/python app.py "${1:-5000}"
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Configurazione pytest comune: env PRIMA dell'import di config.py.
|
||||
|
||||
config.py alza RuntimeError se BOOKISH_DEBUG=false senza BOOKISH_SECRET:
|
||||
per i test usiamo sempre DEBUG + secret di test.
|
||||
Per isolare i test dal volume reale, BOOKISH_DATA_DIR punta a una tmp
|
||||
dedicata, impostata qui così config.py la legge all'import.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_DATA_TMP = tempfile.mkdtemp(prefix="bookish-test-")
|
||||
|
||||
os.environ["BOOKISH_DEBUG"] = "true"
|
||||
os.environ["BOOKISH_SECRET"] = "test-secret-dummy"
|
||||
os.environ["BOOKISH_CLEANUP_HOURS"] = "0"
|
||||
os.environ["BOOKISH_DATA_DIR"] = _DATA_TMP
|
||||
# Rate limit disattivato di default nei test: i test lo abilitano
|
||||
# esplicitamente quando vogliono verificarlo (evita budget esauriti).
|
||||
os.environ["BOOKISH_RATE_LIMIT_UPLOAD"] = "0"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_state():
|
||||
"""Pulisce lo stato globale del rate-limit tra i test."""
|
||||
import bookish.services.security as sec
|
||||
with sec._rate_lock:
|
||||
sec._rate.clear()
|
||||
yield
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Test per R7 (osservabilità): endpoint /metrics e logging strutturato."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
from bookish import create_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(monkeypatch):
|
||||
monkeypatch.setenv("BOOKISH_DEBUG", "true")
|
||||
monkeypatch.setenv("BOOKISH_SECRET", "test-secret")
|
||||
monkeypatch.setenv("BOOKISH_RATE_LIMIT_UPLOAD", "0")
|
||||
importlib.reload(config)
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
yield app
|
||||
|
||||
|
||||
def test_metrics_endpoint_returns_prometheus_text(app):
|
||||
client = app.test_client()
|
||||
# qualche richiesta per popolare i contatori
|
||||
client.get("/")
|
||||
client.get("/healthz")
|
||||
r = client.get("/metrics")
|
||||
assert r.status_code == 200
|
||||
body = r.get_data(as_text=True)
|
||||
assert "text/plain" in r.headers.get("Content-Type", "")
|
||||
# formato Prometheus
|
||||
assert body.startswith("# HELP")
|
||||
assert "bookish_uptime_seconds" in body
|
||||
assert "bookish_active_tasks " in body
|
||||
# la richiesta a / metrics stessa non deve generare loop, ma dev'esserci
|
||||
# almeno la richiesta HTTP totale registrata (route /)
|
||||
assert 'bookish_http_requests_total{method="GET",route="/"}' in body
|
||||
assert "bookish_http_request_duration_seconds_count" in body
|
||||
|
||||
|
||||
def test_http_requests_are_counted_by_route_and_status(app):
|
||||
from bookish.services import observability as obs
|
||||
obs._http.clear()
|
||||
client = app.test_client()
|
||||
client.get("/") # 200
|
||||
client.get("/nope") # 404
|
||||
r = client.get("/metrics")
|
||||
body = r.get_data(as_text=True)
|
||||
# 404 sulla route <unknown> (nessun url_rule) e 200 su /
|
||||
assert 'status="200"' in body
|
||||
assert 'status="404"' in body
|
||||
|
||||
|
||||
def test_conversion_observe_and_active_tasks(app):
|
||||
from bookish.services import observability as obs
|
||||
obs._conversions.clear()
|
||||
obs.track_task(1)
|
||||
obs.conversion_observe("completed", 1.5)
|
||||
obs.conversion_observe("failed", 0.2)
|
||||
obs.track_task(-1)
|
||||
client = app.test_client()
|
||||
r = client.get("/metrics")
|
||||
body = r.get_data(as_text=True)
|
||||
assert 'bookish_conversions_total{result="completed"} 1' in body
|
||||
assert 'bookish_conversions_total{result="failed"} 1' in body
|
||||
assert 'bookish_conversion_duration_seconds_sum{result="completed"} 1.500000' in body
|
||||
assert "bookish_active_tasks 0" in body
|
||||
|
||||
|
||||
def test_json_log_formatter(monkeypatch, capsys):
|
||||
monkeypatch.setenv("LOG_FORMAT", "json")
|
||||
from bookish.services import observability as obs
|
||||
obs.configure_logging(json_output=True)
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("test.obs")
|
||||
log.info("prova %s", "oggetto", extra={"task_id": "abc123"})
|
||||
# il root handler scrive su stderr
|
||||
out = capsys.readouterr().err
|
||||
line = [l for l in out.splitlines() if "prova" in l][-1]
|
||||
payload = json.loads(line)
|
||||
assert payload["message"] == 'prova oggetto'
|
||||
assert payload["level"] == "INFO"
|
||||
assert payload["task_id"] == "abc123"
|
||||
assert "logger" in payload and payload["logger"] == "test.obs"
|
||||
|
||||
|
||||
def test_plain_log_format(monkeypatch, capsys):
|
||||
# default: formato testo
|
||||
import importlib as il
|
||||
import config
|
||||
monkeypatch.delenv("LOG_FORMAT", raising=False)
|
||||
il.reload(config)
|
||||
from bookish.services import observability as obs
|
||||
obs.configure_logging(json_output=False)
|
||||
import logging
|
||||
logging.getLogger("my.obs2").info("plain-line")
|
||||
out = capsys.readouterr().err
|
||||
assert "plain-line" in out
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Test per R2 (auth Basic + rate limit) e R3 (CSP + sanitizzazione).
|
||||
|
||||
L'auth e il rate-limit leggono le var d'ambiente ad import di config.py:
|
||||
per testarli ricreiamo le env e facciamo reload del modulo config prima
|
||||
di creare l'app.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
import config
|
||||
from bookish import create_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(monkeypatch):
|
||||
monkeypatch.setenv("BOOKISH_DEBUG", "true")
|
||||
monkeypatch.setenv("BOOKISH_SECRET", "test-secret")
|
||||
monkeypatch.setenv("BOOKISH_RATE_LIMIT_UPLOAD", "1000")
|
||||
monkeypatch.setenv("BOOKISH_AUTH_USERNAME", "")
|
||||
monkeypatch.setenv("BOOKISH_AUTH_PASSWORD", "")
|
||||
importlib.reload(config)
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _app_with_env(monkeypatch, **env):
|
||||
monkeypatch.setenv("BOOKISH_DEBUG", "true")
|
||||
monkeypatch.setenv("BOOKISH_SECRET", "test-secret")
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
importlib.reload(config)
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
def _basic_auth_header(user, pwd):
|
||||
token = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3 — CSP e header di sicurezza
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_security_headers_on_page(client):
|
||||
resp = client.get("/")
|
||||
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert resp.headers.get("Referrer-Policy") == "no-referrer"
|
||||
assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
|
||||
csp = resp.headers.get("Content-Security-Policy", "")
|
||||
assert "default-src 'self'" in csp
|
||||
assert "object-src 'none'" in csp
|
||||
# tutti i JS del reader sono self-hosted → nessun CDN permesso
|
||||
assert "cdnjs.cloudflare.com" not in csp
|
||||
assert "cdn.jsdelivr.net" not in csp
|
||||
assert "worker-src 'self'" in csp
|
||||
|
||||
|
||||
def test_csp_header_on_api(client):
|
||||
resp = client.get("/api/bookmarks/xyz")
|
||||
assert resp.headers.get("Content-Security-Policy", "") != ""
|
||||
|
||||
|
||||
def test_dompurify_vendored():
|
||||
"""DOMPurify server-only per CSP + offline."""
|
||||
import os
|
||||
p = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||
"bookish", "static", "vendor", "purify.min.js")
|
||||
assert os.path.isfile(p), "vendor/purify.min.js mancante"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R2 — Autenticazione Basic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auth_required_without_creds(monkeypatch):
|
||||
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
|
||||
c = app_.test_client()
|
||||
resp = c.get("/")
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers.get("WWW-Authenticate", "").startswith("Basic")
|
||||
|
||||
|
||||
def test_auth_allowed_with_creds(monkeypatch):
|
||||
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
|
||||
c = app_.test_client()
|
||||
resp = c.get("/", headers=_basic_auth_header("admin", "secret"))
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_auth_rejects_wrong_password(monkeypatch):
|
||||
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
|
||||
c = app_.test_client()
|
||||
resp = c.get("/", headers=_basic_auth_header("admin", "wrong"))
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_auth_healthz_open(monkeypatch):
|
||||
"""healthz resta pubblico anche con auth (per l'healthcheck Docker)."""
|
||||
app_ = _app_with_env(monkeypatch, BOOKISH_AUTH_USERNAME="admin", BOOKISH_AUTH_PASSWORD="secret")
|
||||
c = app_.test_client()
|
||||
resp = c.get("/healthz")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R2 — rate limit su /upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_rate_limit_blocks_after_threshold(monkeypatch):
|
||||
app_ = _app_with_env(monkeypatch, BOOKISH_RATE_LIMIT_UPLOAD="3")
|
||||
c = app_.test_client()
|
||||
fs = lambda: FileStorage(stream=io.BytesIO(b"abc"), filename="t.txt", content_type="text/plain")
|
||||
codes = []
|
||||
for _ in range(4):
|
||||
r = c.post("/upload", data={"file": fs(), "output_format": "md"},
|
||||
content_type="multipart/form-data")
|
||||
codes.append(r.status_code)
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[-1] == 429
|
||||
|
||||
|
||||
def test_rate_limit_disabled_if_zero(monkeypatch):
|
||||
app_ = _app_with_env(monkeypatch, BOOKISH_RATE_LIMIT_UPLOAD="0")
|
||||
c = app_.test_client()
|
||||
fs = lambda: FileStorage(stream=io.BytesIO(b"x"), filename="x.txt", content_type="text/plain")
|
||||
codes = [c.post("/upload", data={"file": fs(), "output_format": "md"},
|
||||
content_type="multipart/form-data").status_code for _ in range(3)]
|
||||
assert codes == [200, 200, 200]
|
||||
|
||||
|
||||
def test_rate_limit_in_response_body(monkeypatch):
|
||||
app_ = _app_with_env(monkeypatch, BOOKISH_RATE_LIMIT_UPLOAD="1")
|
||||
c = app_.test_client()
|
||||
fs = lambda: FileStorage(stream=io.BytesIO(b"x"), filename="x.txt", content_type="text/plain")
|
||||
r1 = c.post("/upload", data={"file": fs(), "output_format": "md"}, content_type="multipart/form-data")
|
||||
r2 = c.post("/upload", data={"file": fs(), "output_format": "md"}, content_type="multipart/form-data")
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 429
|
||||
assert "retry_after" in r2.get_json()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Test focali sul bug critico R1: naming/storage dei file.
|
||||
|
||||
Il flusso reale:
|
||||
|
||||
upload → <UPLOAD_FOLDER>/<uuid>_<filename> (con prefisso uuid)
|
||||
converted → <CONVERTED_FOLDER>/<uuid>.<ext> (senza filename)
|
||||
|
||||
il reader cerca:
|
||||
|
||||
reader.py → UPLOAD_FOLDER/<filename> poi CONVERTED_FOLDER/<filename>
|
||||
senza sapere del prefisso uuid → 404.
|
||||
|
||||
Questi test documentano il comportamento attuale (i test rossi = bug
|
||||
confermato) e la direzione del fix (il reader deve trovare i file per il
|
||||
loro stored_filename / id, non per il nome originale).
|
||||
|
||||
Qui non viene lanciata alcuna conversione reale (niente Pandoc/WeasyPrint):
|
||||
si verifica solo naming e risoluzione dei path.
|
||||
|
||||
Run:
|
||||
.venv/bin/python -m pytest tests/test_storage_paths.py -v
|
||||
"""
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from bookish import create_app
|
||||
|
||||
EPUB_BYTES = b"PK\x03\x04 fake-content (questi test non aprono il file)"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(tmp_path):
|
||||
"""App Flask, eventualmente con DATA_DIR sovrascritto per il singolo test."""
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
# override i folder su una tmp dedicata a questo test
|
||||
app.config["UPLOAD_FOLDER"] = str(tmp_path / "uploads")
|
||||
app.config["CONVERTED_FOLDER"] = str(tmp_path / "converted")
|
||||
Path(app.config["UPLOAD_FOLDER"]).mkdir(parents=True, exist_ok=True)
|
||||
Path(app.config["CONVERTED_FOLDER"]).mkdir(parents=True, exist_ok=True)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _upload_epub(client):
|
||||
file_storage = FileStorage(
|
||||
stream=io.BytesIO(EPUB_BYTES),
|
||||
filename="libro_test.epub",
|
||||
content_type="application/epub+zip",
|
||||
)
|
||||
resp = client.post(
|
||||
"/upload",
|
||||
data={"file": file_storage, "output_format": "epub"},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def test_upload_file_present_on_disk_with_uuid_prefix(client, app):
|
||||
"""R1a — l'upload viene salvato con prefisso uuid (conferma del naming)."""
|
||||
data = _upload_epub(client)
|
||||
stored = data["stored_filename"]
|
||||
assert not stored.startswith("libro_test.epub")
|
||||
assert stored.endswith(".epub")
|
||||
assert Path(app.config["UPLOAD_FOLDER"], stored).is_file()
|
||||
|
||||
|
||||
def test_reader_serve_resolves_uploaded_id(client, app):
|
||||
"""/reader/serve/<stored_filename> deve trovare il file."""
|
||||
data = _upload_epub(client)
|
||||
resp = client.get(f"/reader/serve/{data['stored_filename']}")
|
||||
assert resp.status_code == 200, resp.status_code
|
||||
|
||||
|
||||
def test_reader_page_resolves_uploaded_id(client, app):
|
||||
"""/reader/<stored_filename> deve rendere la pagina reader."""
|
||||
data = _upload_epub(client)
|
||||
resp = client.get(f"/reader/{data['stored_filename']}")
|
||||
assert resp.status_code == 200, resp.status_code
|
||||
|
||||
|
||||
def test_reader_serve_bare_original_name_resolved(client, app):
|
||||
"""Dopo il fix: il reader risolve anche il nome originale di un file
|
||||
convertito (link da localStorage/recenti). Qui l'upload vive in
|
||||
UPLOAD_FOLDER con prefisso uuid → col fix troviamo comunque il file."""
|
||||
data = _upload_epub(client)
|
||||
bare = data["stored_filename"].split("_", 1)[-1] # libro_test.epub
|
||||
resp = client.get(f"/reader/serve/{bare}")
|
||||
assert resp.status_code == 200, resp.status_code
|
||||
|
||||
|
||||
def test_reader_serve_converted_by_bare_name_not_possible(client, app):
|
||||
"""NOTA: un file convertito (<uuid>.ext) NON contiene il nome originale:
|
||||
senza un mapping esplicito non è risolvibile dal bare name, e va bene.
|
||||
La UI usa sempre l'ID (stored filename) per i convertiti. Questo test
|
||||
fissa il comportamento a 404 per evitare regressioni/false promesse."""
|
||||
conv_dir = Path(app.config["CONVERTED_FOLDER"])
|
||||
conv_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_name = f"{uuid.uuid4().hex}.epub"
|
||||
(conv_dir / out_name).write_bytes(EPUB_BYTES)
|
||||
|
||||
resp = client.get("/reader/serve/libro_test.epub")
|
||||
assert resp.status_code == 404, resp.status_code
|
||||
|
||||
|
||||
def test_reader_resolves_converted_by_id(client, app):
|
||||
"""R1b — un file convertito <uuid>.ext in CONVERTED_FOLDER viene
|
||||
risolto dal reader per il suo ID (stored name), non col nome originale."""
|
||||
conv_dir = Path(app.config["CONVERTED_FOLDER"])
|
||||
conv_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_name = f"{uuid.uuid4().hex}.epub"
|
||||
(conv_dir / out_name).write_bytes(EPUB_BYTES)
|
||||
|
||||
resp = client.get(f"/reader/serve/{out_name}")
|
||||
assert resp.status_code == 200, resp.status_code
|
||||
|
||||
resp = client.get(f"/reader/{out_name}")
|
||||
assert resp.status_code == 200, resp.status_code
|
||||
Reference in new issue
Block a user