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 //