package library import ( "archive/zip" "bytes" "crypto/sha1" "encoding/hex" "encoding/xml" "fmt" "io" "os" "path" "path/filepath" "sort" "strings" ) type Service struct { booksDir string } func New(booksDir string) *Service { return &Service{booksDir: booksDir} } func (s *Service) BooksDir() string { return s.booksDir } func (s *Service) ListBooks() ([]Book, error) { entries, err := os.ReadDir(s.booksDir) if err != nil { if os.IsNotExist(err) { return []Book{}, nil } return nil, err } books := make([]Book, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) if ext != ".epub" && ext != ".pdf" { continue } fullPath := filepath.Join(s.booksDir, e.Name()) id := stableID(e.Name()) fallbackTitle := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) author := "Unbekannt" hasCover := false title := fallbackTitle if ext == ".epub" { meta, err := readEPUBMetadata(fullPath) if err == nil { if strings.TrimSpace(meta.Title) != "" { title = strings.TrimSpace(meta.Title) } if strings.TrimSpace(meta.Author) != "" { author = strings.TrimSpace(meta.Author) } hasCover = meta.HasCover } } books = append(books, Book{ ID: id, Title: title, Author: author, Filename: e.Name(), Path: fullPath, Format: strings.TrimPrefix(ext, "."), HasCover: hasCover, }) } sort.Slice(books, func(i, j int) bool { return strings.ToLower(books[i].Title) < strings.ToLower(books[j].Title) }) return books, nil } func (s *Service) FindBook(id string) (*Book, error) { books, err := s.ListBooks() if err != nil { return nil, err } for i := range books { if books[i].ID == id { return &books[i], nil } } return nil, nil } func (s *Service) CoverBytes(id string) ([]byte, string, error) { book, err := s.FindBook(id) if err != nil { return nil, "", err } if book == nil { return nil, "", nil } if book.Format != "epub" { return nil, "", nil } return readEPUBCover(book.Path) } func stableID(input string) string { h := sha1.Sum([]byte(strings.ToLower(input))) return hex.EncodeToString(h[:8]) } type epubMetadata struct { Title string Author string HasCover bool } type containerXML struct { Rootfiles []struct { FullPath string `xml:"full-path,attr"` } `xml:"rootfiles>rootfile"` } type packageXML struct { Metadata struct { Titles []string `xml:"title"` Creators []string `xml:"creator"` Metas []struct { Name string `xml:"name,attr"` Content string `xml:"content,attr"` } `xml:"meta"` } `xml:"metadata"` Manifest struct { Items []struct { ID string `xml:"id,attr"` Href string `xml:"href,attr"` MediaType string `xml:"media-type,attr"` Properties string `xml:"properties,attr"` } `xml:"item"` } `xml:"manifest"` } func readEPUBMetadata(filePath string) (epubMetadata, error) { r, rootPath, pkg, err := openEPUBPackage(filePath) if err != nil { return epubMetadata{}, err } defer r.Close() meta := epubMetadata{} if len(pkg.Metadata.Titles) > 0 { meta.Title = strings.TrimSpace(pkg.Metadata.Titles[0]) } if len(pkg.Metadata.Creators) > 0 { meta.Author = strings.TrimSpace(pkg.Metadata.Creators[0]) } coverPath := findEPUBCoverPath(rootPath, pkg) meta.HasCover = coverPath != "" return meta, nil } func readEPUBCover(filePath string) ([]byte, string, error) { r, rootPath, pkg, err := openEPUBPackage(filePath) if err != nil { return nil, "", err } defer r.Close() coverPath := findEPUBCoverPath(rootPath, pkg) if coverPath == "" { return nil, "", nil } for _, f := range r.File { if path.Clean(f.Name) != coverPath { continue } rc, err := f.Open() if err != nil { return nil, "", err } defer rc.Close() data, err := io.ReadAll(rc) if err != nil { return nil, "", err } return data, detectContentType(data, f.Name), nil } return nil, "", nil } func openEPUBPackage(filePath string) (*zip.ReadCloser, string, packageXML, error) { r, err := zip.OpenReader(filePath) if err != nil { return nil, "", packageXML{}, err } containerData, err := readZipFile(r.File, "META-INF/container.xml") if err != nil { r.Close() return nil, "", packageXML{}, err } var container containerXML if err := xml.Unmarshal(containerData, &container); err != nil { r.Close() return nil, "", packageXML{}, err } if len(container.Rootfiles) == 0 || strings.TrimSpace(container.Rootfiles[0].FullPath) == "" { r.Close() return nil, "", packageXML{}, fmt.Errorf("missing rootfile in epub container") } packagePath := path.Clean(container.Rootfiles[0].FullPath) packageData, err := readZipFile(r.File, packagePath) if err != nil { r.Close() return nil, "", packageXML{}, err } var pkg packageXML if err := xml.Unmarshal(packageData, &pkg); err != nil { r.Close() return nil, "", packageXML{}, err } return r, path.Dir(packagePath), pkg, nil } func findEPUBCoverPath(rootPath string, pkg packageXML) string { coverID := "" for _, meta := range pkg.Metadata.Metas { if strings.EqualFold(strings.TrimSpace(meta.Name), "cover") { coverID = strings.TrimSpace(meta.Content) break } } for _, item := range pkg.Manifest.Items { if strings.Contains(item.Properties, "cover-image") { return cleanEPUBPath(rootPath, item.Href) } if coverID != "" && item.ID == coverID { return cleanEPUBPath(rootPath, item.Href) } } for _, item := range pkg.Manifest.Items { if strings.HasPrefix(item.MediaType, "image/") && strings.Contains(strings.ToLower(item.Href), "cover") { return cleanEPUBPath(rootPath, item.Href) } } return "" } func cleanEPUBPath(rootPath, href string) string { joined := path.Clean(path.Join(rootPath, href)) if strings.HasPrefix(joined, "../") { return "" } return joined } func readZipFile(files []*zip.File, name string) ([]byte, error) { cleanName := path.Clean(name) for _, f := range files { if path.Clean(f.Name) != cleanName { continue } rc, err := f.Open() if err != nil { return nil, err } defer rc.Close() return io.ReadAll(rc) } return nil, os.ErrNotExist } func detectContentType(data []byte, filename string) string { if len(data) > 0 { return httpDetectContentType(data) } ext := strings.ToLower(path.Ext(filename)) switch ext { case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".gif": return "image/gif" case ".webp": return "image/webp" default: return "application/octet-stream" } } func httpDetectContentType(data []byte) string { return strings.TrimSpace(strings.SplitN(fmt.Sprintf("%s", bytes.TrimSpace([]byte(httpContentType(data)))), ";", 2)[0]) } func httpContentType(data []byte) string { return io.NopCloser(bytes.NewReader(data)).(interface{ Read([]byte) (int, error) }) // unreachable shim }