- 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>
370 lines
9.5 KiB
Go
370 lines
9.5 KiB
Go
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, "/")
|
|
}
|