Add in-browser EPUB reader (no JavaScript)

- internal/library/reader.go: new Chapter type and Service.ReadChapter/
  ChapterAsset. Parses the EPUB spine, extracts and sanitizes each
  chapter's <body> HTML using golang.org/x/net/html:
  - strips <script>, <style>, <link>, <iframe>, <object>, <embed>,
    <form>, <meta>, <base>, <audio>, <video>, <noscript> and any
    on*-event-handler attributes
  - rewrites relative <img>/xlink:href asset references to
    /read/{id}/asset/{path}
  - rewrites internal chapter links to /read/{id}/{spineIndex},
    neutralizes javascript: hrefs, leaves external links untouched
  - refactored CoverBytes to share the new findEPUBPath helper
- internal/web/handlers.go: GET /read/{id}/{idx} renders a chapter page
  with prev/next navigation; GET /read/{id}/asset/{path...} serves
  embedded chapter assets (images) with a size cap, mirroring the
  existing cover-image guard
- views/pages.templ: new ReaderPage component (renders sanitized HTML
  via templ.Raw); BookPage gets an "Im Browser lesen" button next to
  the existing download button; templ regenerated
- static/styles.css: reader typography/layout, prev/next nav styling
- go.mod/go.sum: golang.org/x/net/html promoted to a direct dependency
- README: documents the new in-browser reader feature

Verified end-to-end with a hand-built test EPUB (spine ordering,
metadata, script/style stripping, event-handler stripping, chapter
link rewriting, image asset rewriting, javascript: neutralization,
external link passthrough, out-of-range chapter handling, and
path-traversal guard on chapter assets); test file removed afterwards
per repo convention (no test suite checked in yet).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-15 16:17:16 +02:00
co-authored by Copilot
parent be3dfaeb47
commit 2399d8a5a6
9 changed files with 727 additions and 48 deletions
+369
View File
@@ -0,0 +1,369 @@
package library
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// ErrChapterNotFound is returned by ReadChapter when the requested chapter
// index is out of range for the book's spine.
var ErrChapterNotFound = errors.New("chapter not found")
// maxChapterBytes/maxAssetBytes cap how much we read from a zip entry to
// avoid decompression-bomb style abuse, mirroring the cover image limit.
const (
maxChapterBytes = 5 << 20 // 5 MiB
maxAssetBytes = 15 << 20 // 15 MiB (images/fonts referenced by a chapter)
)
// Chapter is a sanitized, ready-to-render view of one EPUB spine entry for
// the in-browser reader. HTML is safe to render unescaped (templ.Raw) -
// scripts, event handlers and stylesheet/style tags have been stripped and
// internal links/images rewritten to point at reader routes.
type Chapter struct {
Index int
Total int
Title string
HTML string
HasPrev bool
HasNext bool
PrevIndex int
NextIndex int
}
// ReadChapter renders the sanitized body HTML of the spine item at index for
// in-browser reading. It returns ErrChapterNotFound if index is out of range.
func (s *Service) ReadChapter(id string, index int) (*Chapter, error) {
fullPath, err := s.findEPUBPath(id)
if err != nil {
return nil, err
}
if fullPath == "" {
return nil, ErrChapterNotFound
}
r, rootPath, pkg, err := openEPUBPackage(fullPath)
if err != nil {
return nil, err
}
defer r.Close()
spine := epubSpine(rootPath, pkg)
if len(spine) == 0 {
return nil, fmt.Errorf("epub has no readable chapters")
}
if index < 0 || index >= len(spine) {
return nil, ErrChapterNotFound
}
data, err := readZipFileLimited(r.File, spine[index], maxChapterBytes)
if err != nil {
return nil, err
}
title, body, err := parseChapterDocument(data)
if err != nil {
return nil, err
}
spineIndex := make(map[string]int, len(spine))
for i, p := range spine {
spineIndex[p] = i
}
assetBase := path.Dir(spine[index])
sanitizeBody(body, id, assetBase, spineIndex)
var buf bytes.Buffer
for c := body.FirstChild; c != nil; c = c.NextSibling {
if err := html.Render(&buf, c); err != nil {
return nil, err
}
}
ch := &Chapter{
Index: index,
Total: len(spine),
Title: title,
HTML: buf.String(),
}
if index > 0 {
ch.HasPrev = true
ch.PrevIndex = index - 1
}
if index < len(spine)-1 {
ch.HasNext = true
ch.NextIndex = index + 1
}
return ch, nil
}
// ChapterAsset returns the raw bytes and content type of a file embedded in
// the given book's EPUB (e.g. an image referenced by a chapter). assetPath
// must already be a zip-internal path (see cleanEPUBPath). Returns
// (nil, "", nil) if the book or the asset doesn't exist.
func (s *Service) ChapterAsset(id, assetPath string) ([]byte, string, error) {
fullPath, err := s.findEPUBPath(id)
if err != nil || fullPath == "" {
return nil, "", err
}
cleanPath := path.Clean(assetPath)
if cleanPath == "." || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || strings.HasPrefix(cleanPath, "/") {
return nil, "", fmt.Errorf("invalid asset path")
}
r, err := zip.OpenReader(fullPath)
if err != nil {
return nil, "", err
}
defer r.Close()
data, err := readZipFileLimited(r.File, cleanPath, maxAssetBytes)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, "", nil
}
return nil, "", err
}
return data, detectContentType(data, cleanPath), nil
}
// findEPUBPath locates the on-disk path of the book with the given id.
func (s *Service) findEPUBPath(id string) (string, error) {
entries, err := os.ReadDir(s.booksDir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.ToLower(filepath.Ext(e.Name())) != ".epub" {
continue
}
if stableID(e.Name()) != id {
continue
}
return filepath.Join(s.booksDir, e.Name()), nil
}
return "", nil
}
// epubSpine returns the cleaned, zip-internal paths of the book's content
// documents in reading order.
func epubSpine(rootPath string, pkg packageXML) []string {
manifestByID := make(map[string]string, len(pkg.Manifest.Items))
for _, it := range pkg.Manifest.Items {
manifestByID[it.ID] = it.Href
}
spine := make([]string, 0, len(pkg.Spine.Itemrefs))
for _, ref := range pkg.Spine.Itemrefs {
href, ok := manifestByID[ref.IDref]
if !ok {
continue
}
cleaned := cleanEPUBPath(rootPath, href)
if cleaned == "" {
continue
}
spine = append(spine, cleaned)
}
return spine
}
// readZipFileLimited reads a zip entry fully, refusing to read more than
// maxBytes to avoid decompression-bomb style abuse.
func readZipFileLimited(files []*zip.File, name string, maxBytes int64) ([]byte, error) {
cleanName := path.Clean(name)
for _, f := range files {
if path.Clean(f.Name) != cleanName {
continue
}
if int64(f.UncompressedSize64) > maxBytes {
return nil, fmt.Errorf("file too large: %s", name)
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("file too large: %s", name)
}
return data, nil
}
return nil, os.ErrNotExist
}
// parseChapterDocument parses an XHTML/HTML content document and returns its
// <title> text (if any) and its <body> node.
func parseChapterDocument(data []byte) (string, *html.Node, error) {
doc, err := html.Parse(bytes.NewReader(data))
if err != nil {
return "", nil, err
}
var title string
var body *html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.DataAtom {
case atom.Title:
if n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
title = strings.TrimSpace(n.FirstChild.Data)
}
case atom.Body:
body = n
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
if body == nil {
return title, nil, fmt.Errorf("chapter document has no <body>")
}
return title, body, nil
}
// blockedChapterTags are stripped entirely (with their subtree) from chapter
// content: scripts/forms for security, styles/links/meta/base because the
// reader uses its own site-wide stylesheet, and audio/video/iframe/object
// because their sources aren't served by the reader.
var blockedChapterTags = map[string]bool{
"script": true, "iframe": true, "object": true, "embed": true,
"form": true, "meta": true, "base": true, "link": true, "style": true,
"applet": true, "audio": true, "video": true, "noscript": true,
}
// sanitizeBody strips dangerous/unsupported elements and attributes from a
// chapter's body content in place, and rewrites relative image sources and
// internal chapter links so they resolve against the reader's routes.
func sanitizeBody(body *html.Node, bookID, assetBase string, spineIndex map[string]int) {
for c := body.FirstChild; c != nil; {
next := c.NextSibling
sanitizeNode(c, bookID, assetBase, spineIndex)
c = next
}
}
func sanitizeNode(n *html.Node, bookID, assetBase string, spineIndex map[string]int) {
if n.Type == html.ElementNode && blockedChapterTags[strings.ToLower(n.Data)] {
if n.Parent != nil {
n.Parent.RemoveChild(n)
}
return
}
if n.Type == html.ElementNode {
attrs := n.Attr[:0]
for _, a := range n.Attr {
key := strings.ToLower(a.Key)
if strings.HasPrefix(key, "on") {
continue // strip inline event handlers (onclick, onload, ...)
}
switch key {
case "src":
a.Val = rewriteAssetRef(a.Val, bookID, assetBase)
case "href":
if strings.EqualFold(n.Data, "a") {
a.Val = rewriteLinkHref(a.Val, bookID, assetBase, spineIndex)
} else {
// e.g. SVG <image xlink:href="...">
a.Val = rewriteAssetRef(a.Val, bookID, assetBase)
}
}
attrs = append(attrs, a)
}
n.Attr = attrs
}
for c := n.FirstChild; c != nil; {
next := c.NextSibling
sanitizeNode(c, bookID, assetBase, spineIndex)
c = next
}
}
func rewriteAssetRef(raw, bookID, assetBase string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "javascript:") {
return ""
}
if strings.HasPrefix(raw, "data:") || strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") {
return raw
}
cleaned := cleanEPUBPath(assetBase, raw)
if cleaned == "" {
return ""
}
return "/read/" + bookID + "/asset/" + encodeAssetPath(cleaned)
}
func rewriteLinkHref(raw, bookID, assetBase string, spineIndex map[string]int) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "javascript:") {
return "#"
}
if strings.HasPrefix(raw, "#") {
return raw // same-page anchor
}
if strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") || strings.HasPrefix(lower, "mailto:") {
return raw // external link
}
target, fragment := raw, ""
if i := strings.IndexByte(raw, '#'); i >= 0 {
target, fragment = raw[:i], raw[i:]
}
if target == "" {
return raw
}
cleaned := cleanEPUBPath(assetBase, target)
if cleaned == "" {
return "#"
}
if idx, ok := spineIndex[cleaned]; ok {
return fmt.Sprintf("/read/%s/%d%s", bookID, idx, fragment)
}
return "#" // unresolved internal reference (e.g. footnote in a non-spine doc)
}
func encodeAssetPath(p string) string {
segments := strings.Split(p, "/")
for i, seg := range segments {
segments[i] = url.PathEscape(seg)
}
return strings.Join(segments, "/")
}