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 // 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, "/") }