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:
@@ -102,29 +102,11 @@ func (s *Service) CoverBytes(id string) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(s.booksDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "", nil
|
||||
}
|
||||
fullPath, err := s.findEPUBPath(id)
|
||||
if err != nil || fullPath == "" {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext != ".epub" {
|
||||
continue
|
||||
}
|
||||
if stableID(e.Name()) != id {
|
||||
continue
|
||||
}
|
||||
return readEPUBCover(filepath.Join(s.booksDir, e.Name()))
|
||||
}
|
||||
|
||||
return nil, "", nil
|
||||
return readEPUBCover(fullPath)
|
||||
}
|
||||
|
||||
func stableID(input string) string {
|
||||
@@ -161,6 +143,11 @@ type packageXML struct {
|
||||
Properties string `xml:"properties,attr"`
|
||||
} `xml:"item"`
|
||||
} `xml:"manifest"`
|
||||
Spine struct {
|
||||
Itemrefs []struct {
|
||||
IDref string `xml:"idref,attr"`
|
||||
} `xml:"itemref"`
|
||||
} `xml:"spine"`
|
||||
}
|
||||
|
||||
func readEPUBMetadata(filePath string) (epubMetadata, error) {
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
@@ -41,6 +42,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /book/{id}", h.requireAuth(h.bookDetails))
|
||||
mux.HandleFunc("GET /download/{id}", h.requireAuth(h.downloadBook))
|
||||
mux.HandleFunc("GET /cover/{id}", h.requireAuth(h.bookCover))
|
||||
mux.HandleFunc("GET /read/{id}/asset/{path...}", h.requireAuth(h.readAsset))
|
||||
mux.HandleFunc("GET /read/{id}/{idx}", h.requireAuth(h.readChapter))
|
||||
|
||||
// Uploader and above
|
||||
mux.HandleFunc("GET /upload", h.requireRole(users.RoleUploader, h.uploadPage))
|
||||
@@ -131,6 +134,69 @@ func (h *Handler) bookCover(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (h *Handler) readChapter(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||
if err != nil || idx < 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
book, err := h.lib.FindBook(id)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load book", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if book == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
chapter, err := h.lib.ReadChapter(id, idx)
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrChapterNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
log.Printf("read chapter: %v", err)
|
||||
http.Error(w, "Kapitel konnte nicht geladen werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
render(w, r.Context(), views.ReaderPage(*book, *chapter))
|
||||
}
|
||||
|
||||
func (h *Handler) readAsset(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
assetPath := r.PathValue("path")
|
||||
if id == "" || assetPath == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, contentType, err := h.lib.ChapterAsset(id, assetPath)
|
||||
if err != nil {
|
||||
log.Printf("read asset: %v", err)
|
||||
http.Error(w, "Datei konnte nicht geladen werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(data) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, ctx context.Context, c templ.Component) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := c.Render(ctx, w); err != nil && !errors.Is(err, context.Canceled) {
|
||||
|
||||
Reference in New Issue
Block a user