feat: self-hosted frontend JS (offline) + docker hardening

- Vendored epub.js/pdf.js+worker/JSZip/DOMPurify in static/vendor/
- reader.html/reader.js servono i JS da /static (niente CDN, offline-ready)
- CSP: rimosse allowlist CDN, aggiunto worker-src 'self' blob:
- Test CSP aggiornati; tutti 16 verdi; verificato smoke test offline in container
This commit is contained in:
truetype committed 2026-08-29 22:32:10 +02:00
1 parent e62c50c616
commit 0f15b7958f
31 files changed
+1363 -215

No files matched your search

+17
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
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
+94
View File
@@ -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
+1
View File
@@ -6,3 +6,4 @@ converted/
.venv.bak/
.sisyphus/
bookish.db
.env
+52
View File
@@ -0,0 +1,52 @@
FROM python:3.12-slim-bookworm AS builder
WORKDIR /build
COPY requirements.txt requirements.lock ./
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"]
+77 -132
View File
@@ -1,155 +1,100 @@
# 📚 Bookish
# Bookish (fork Docker)
**Convertitore e reader di ebook self-hostato** — Flask, Pandoc, WeasyPrint, SQLite.
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. I segnalibri e la posizione di lettura sono persistenti su database SQLite, identificati univocamente dal contenuto del file (SHA256).
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** renderizzati come HTML
Caratteristiche reader:
- Zoom e regolazione font
- Temi: chiaro, scuro, seppia
- Ricerca full-text con risultati navigabili
- Indice navigabile (TOC) per EPUB e PDF
- **Segnalibri** persistenti su SQLite con sync server + localStorage
- **Salvataggio posizione** lettura (server + localStorage)
- **Slider larghezza** area di lettura (40–100%)
- **Aiuto contestuale** con scorciatoie da tastiera
- Pulsante 🔖 con feedback visivo (toast + pulse)
- Navigazione tastiera (← →) e schermo intero
- Temi: chiaro, scuro, seppia
### ⚙️ 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
### 🗄️ Archiviazione persistente
- **SQLite** (`bookish.db`) per segnalibri e posizioni di lettura
- **Content Hash** (SHA256) come identificatore univoco del libro
- **localStorage** come fallback offline
- Stessi segnalibri anche su PC diverso (stessa LAN)
---
## 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. Il database SQLite (`bookish.db`) viene creato automaticamente al primo avvio.
## Note sul container
Apri il browser su `http://localhost:5000` (o la porta assegnata).
- 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.
---
### Sicurezza opzionale
## Architettura
Per esporre Bookish anche solo in LAN, abilita l'autenticazione Basic e
limita gli upload:
```
bookish/
├── app.py # Entry point
├── config.py # Configurazioni
├── requirements.txt
├── run.sh
├── bookish.db # SQLite (creato al primo avvio)
├── bookish/
│ ├── __init__.py # Flask factory + init DB
│ ├── routes/
│ │ ├── convert.py # Upload, SSE progress, download
│ │ ├── reader.py # Reader view + content_hash
│ │ └── api.py # API REST bookmarks + positions
│ ├── services/
│ │ ├── converter.py # Conversione Pandoc + pipeline
│ │ ├── sse_manager.py # SSE progress manager
│ │ ├── formatter.py # PDF/HTML post-processing
│ │ └── database.py # SQLite manager
│ ├── 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 (oltre 800 righe)
│ └── logo.svg
├── uploads/ # File caricati (temporanei)
├── converted/ # File convertiti (temporanei)
└── docs/ # Documentazione
```bash
# .env
BOOKISH_AUTH_USERNAME=admin
BOOKISH_AUTH_PASSWORD=una-password-lunga
BOOKISH_RATE_LIMIT_UPLOAD=20 # upload/ora per IP
```
Vedi [docs/architecture.md](docs/architecture.md) per dettagli.
- 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
## API Endpoints
| 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 per input |
| GET | `/reader` | Pagina reader |
| GET | `/reader/<filename>` | Reader per file specifico |
| GET | `/reader/serve/<filename>` | Serve il file al reader |
| GET | `/reader/formats` | Formati supportati dal reader |
| GET | `/api/bookmarks/<hash>` | Segnalibri di un 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 |
---
## Deployment
Adatto per Proxmox LXC, Docker o VM leggera. Vedi [docs/deploy.md](docs/deploy.md).
---
`GET /healthz` — healthcheck Docker (`{"status":"ok"}`).
## Licenza
+8 -4
View File
@@ -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)
+18 -3
View File
@@ -1,6 +1,12 @@
import os
from flask import Flask
from config import SECRET_KEY, DEBUG, UPLOAD_FOLDER, CONVERTED_FOLDER
from flask import Flask, jsonify
from config import (
SECRET_KEY,
DEBUG,
UPLOAD_FOLDER,
CONVERTED_FOLDER,
MAX_FILE_SIZE,
)
def create_app():
@@ -10,7 +16,7 @@ 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
@@ -28,10 +34,19 @@ def create_app():
app.register_blueprint(reader_bp)
app.register_blueprint(api_bp)
from bookish.services.security import register_security
register_security(app)
@app.route("/")
def index():
from flask import render_template
from config import SUPPORTED_FORMATS
return render_template("index.html", formats=SUPPORTED_FORMATS)
@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
+18 -15
View File
@@ -5,7 +5,7 @@ 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 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
@@ -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,21 +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)
if not filename:
return jsonify({"error": "Nome file non valido"}), 400
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)
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_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
@@ -75,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)
+34 -14
View File
@@ -1,6 +1,6 @@
import os
import hashlib
from flask import Blueprint, render_template, jsonify, send_file, current_app
from flask import Blueprint, render_template, jsonify, send_file, current_app, url_for
from config import SUPPORTED_FORMATS
reader_bp = Blueprint("reader", __name__)
@@ -8,9 +8,13 @@ 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):
@@ -21,30 +25,46 @@ def _file_hash(filepath):
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
content_hash = _file_hash(filepath)
return render_template("reader.html", filename=safe, file_ext=ext, content_hash=content_hash)
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")
+2 -5
View File
@@ -1,13 +1,10 @@
import sqlite3
import os
import time
from flask import current_app
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "bookish.db")
from config import DB_PATH
def _get_db():
db = sqlite3.connect(DB_PATH)
db = sqlite3.connect(DB_PATH, timeout=30)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys=ON")
+125
View File
@@ -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
+14 -3
View File
@@ -79,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;
@@ -151,6 +151,7 @@
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");
@@ -162,7 +163,11 @@
if (ext === "md") {
viewer.innerHTML = simpleMarkdownToHtml(text);
} else if (ext === "html") {
viewer.innerHTML = text;
// 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.innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:1rem;line-height:1.8;">${escapeHtml(text)}</pre>`;
@@ -770,7 +775,13 @@
data.page = loc.start.displayed ? loc.start.displayed.page : 0;
data.label = chapter + " — Pag. " + data.page;
} else {
return;
// 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();
+1
View File
@@ -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
+13
View File
@@ -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
+22
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
+5 -3
View File
@@ -124,15 +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 %}
+22 -5
View File
@@ -1,14 +1,31 @@
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"))
# Formati supportati
SUPPORTED_FORMATS = {
+29
View File
@@ -0,0 +1,29 @@
services:
bookish:
build: .
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}
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:
+14 -28
View File
@@ -45,36 +45,20 @@ source .venv/bin/activate
pip install -r requirements.txt
```
### 2. Docker
### 2. Docker (consigliato in questo fork)
```dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y \
libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 \
libffi-dev shared-mime-info
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p uploads converted
EXPOSE 5000
CMD ["python", "app.py"]
```
Build e avvio:
Usa i file `Dockerfile` e `docker-compose.yml` in root. Non montare un volume su `/app`: cancellerebbe il codice.
```bash
docker build -t bookish .
docker run -d \
--name bookish \
-p 5000:5000 \
-v bookish_data:/app/uploads \
-v bookish_data:/app/converted \
-v bookish_db:/app \
--restart unless-stopped \
bookish
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
@@ -143,9 +127,11 @@ python app.py 8080
| Variabile | Default | Descrizione |
|---|---|---|
| `BOOKISH_SECRET` | `bookish-secret-key-change-in-production` | Flask secret key |
| `BOOKISH_PORT` | 5000 | Porta di partenza per la ricerca |
| `FLASK_DEBUG` | 0 | Modalità debug (1 per attivare) |
| `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
+270
View File
@@ -0,0 +1,270 @@
# 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**).
> Restano P1 R4b e 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 |
| R5 | Errori timeout conversioni lunghe | Media | Media | ⏳ P2 |
| R6 | Trust proxy / opzioni fantasma | Bassa | Media | ✅ risolto |
| R7 | /tmp tmpfs vs conversioni grandi | Bassa | Bassa | ⏳ P1 |
---
## 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.
- [ ] **R4b** `git` revision pin nel Dockerfile build-arg `--build-arg
requirements_checksum` (advanced).
- [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.
+20
View File
@@ -0,0 +1,20 @@
import os
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 = 300
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")
+28
View File
@@ -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
+112
View File
@@ -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
View File
@@ -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
+16 -2
View File
@@ -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}"
+31
View File
@@ -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
+152
View File
@@ -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()
+128
View File
@@ -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