From b6f13b126acb62f036fa508cfc05120a718aea16 Mon Sep 17 00:00:00 2001 From: Nguyễn Gia Phong Date: Thu, 15 Dec 2022 15:02:43 +0900 Subject: Implement Atom feed Implements: https://todo.sr.ht/~cnx/phylactery/2 --- README.md | 4 +- atom.go | 129 +++++++++++++++++++++++++++++++++ main.go | 177 ---------------------------------------------- misc.go | 52 ++++++++++++++ serv.go | 180 +++++++++++++++++++++++++++++++++++++++++++++++ templates/directory.html | 1 + 6 files changed, 364 insertions(+), 179 deletions(-) create mode 100644 atom.go delete mode 100644 main.go create mode 100644 misc.go create mode 100644 serv.go diff --git a/README.md b/README.md index c98f657..118af79 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Phylactery is a web server rendering comic books directly from ZIP archives. ## Installation - go build -o $prefix/bin/phylactery main.go + go build -o $prefix/bin/phylactery *.go ## Usage @@ -14,7 +14,7 @@ to the listening address and run `phylactery`. ## Hacking - PHYLACTERY_LIBRARY=/path/to/library PHYLACTERY_ADDRESS=:42069 go run main.go + PHYLACTERY_LIBRARY=/path/to/library PHYLACTERY_ADDRESS=:42069 go run *.go ## Contributing diff --git a/atom.go b/atom.go new file mode 100644 index 0000000..d905348 --- /dev/null +++ b/atom.go @@ -0,0 +1,129 @@ +// Atom feed synthesizer +// Copyright (C) 2022 Nguyễn Gia Phong +// +// This file is part of Phylactery. +// +// Phylactery is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Phylactery is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Phylactery. If not, see . + +package main + +import ( + "archive/zip" + "encoding/xml" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Type Img represents an XHTML img tag. +type Img struct { + Src string `xml:"src,attr"` + Alt string `xml:"alt,attr"` +} + +// Type Div represents an XHTML div tag. +type Div struct { + NS string `xml:"xmlns,attr"` + Img []Img `xml:"img"` +} + +// Type AtomLink represents an atom:link element. +type AtomLink struct { + Rel string `xml:"rel,attr"` + Href string `xml:"href,attr"` +} + +// Type AtomContent represents an atom:content element. +type AtomContent struct { + Type string `xml:"type,attr"` + Div Div `xml:"div"` +} + +// Type AtomEntry represents an Atom Entry Document. +type AtomEntry struct { + Title string `xml:"title"` + ID string `xml:"id"` + Link []AtomLink `xml:"link"` + Updated time.Time `xml:"updated"` + Content AtomContent `xml:"content"` +} + +// Type AtomFeed represents an Atom Feed Document. +type AtomFeed struct { + XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"` + Title string `xml:"title"` + ID string `xml:"id"` + Link []AtomLink `xml:"link"` + Updated time.Time `xml:"updated"` + Author string `xml:"author>name"` + Entry []AtomEntry `xml:"entry"` +} + +// Function synthesizeAtom generates an Atom feed for given directory. +func synthesizeAtom(r *http.Request, dir string, updated time.Time) AtomFeed { + baseURL := "http://" + r.Host + if r.TLS != nil { + baseURL = "https" + baseURL[4:] + } + var entries []AtomEntry + filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + name := strings.TrimPrefix(p, dir)[1:] + u := escape(baseURL + path.Join(r.URL.Path, name)) + var images []Img + cbz, err := zip.OpenReader(p) + if err != nil { + return nil + } + defer cbz.Close() + for i, f := range cbz.File { + if isImageFile(f) { + continue + } + images = append(images, + Img{u + "?entry=" + strconv.Itoa(i), f.Name}) + } + + info, _ := d.Info() + entries = append(entries, AtomEntry{ + Title: name, + ID: name, + Link: []AtomLink{{"alternate", u}}, + Updated: info.ModTime(), + Content: AtomContent{ + "xhtml", + Div{"http://www.w3.org/1999/xhtml", images}, + }, + }) + return nil + }) + + id := escape(baseURL + r.URL.Path) + return AtomFeed{ + Title: r.URL.Path[1 : len(r.URL.Path)-1], + ID: id, + Link: []AtomLink{ + {"alternate", id}, + {"self", id + "?feed=atom"}, + }, + Updated: updated, + Entry: entries, + } +} diff --git a/main.go b/main.go deleted file mode 100644 index dfc01a5..0000000 --- a/main.go +++ /dev/null @@ -1,177 +0,0 @@ -// Server entry point -// Copyright (C) 2022 Nguyễn Gia Phong -// -// This file is part of Phylactery. -// -// Phylactery is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Phylactery is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with Phylactery. If not, see . - -package main - -import ( - "archive/zip" - "embed" - "html/template" - "io" - "log" - "net/http" - "os" - "path" - "strconv" - "strings" -) - -//go:embed static/* -var static embed.FS - -//go:embed templates/*.html -var templates embed.FS - -// Type Page represents a comic page. -type Page struct { - Index int - Name string -} - -// Type Archive represents a comic book zip archive. -type Archive struct { - Title string - Prev string - Next string -} - -// Type Directory represents a library directory in file system. -type Directory struct { - Title string - Entries []string -} - -// Function escape ensures that question marks in path -// are not recognized as URL parameters. -func escape(name string) template.URL { - return template.URL(strings.Replace(name, "?", "%3f", -1)) -} - -// Function find searches for the directory entry of given name. -func find(entries []os.DirEntry, name string) int { - for i, entry := range entries { - if entry.Name() == name { - return i - } - } - return -1 -} - -// Function main starts Phylactery serving comics from PHYLACTERY_LIBRARY -// and listening on PHYLACTERY_ADDRESS. -func main() { - http.Handle("/static/", http.FileServer(http.FS(static))) - t, err := template.New("").Funcs(template.FuncMap{ - "escape": escape, - }).ParseFS(templates, "templates/*.html") - if err != nil { - log.Fatal(err) - } - lib := os.Getenv("PHYLACTERY_LIBRARY") - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - p := path.Join(lib, path.Clean(r.URL.Path)) - stat, err := os.Stat(p) - if err != nil { - http.NotFound(w, r) - return - } - - if stat.IsDir() { - // Redirect URL without a trailing slash - // pointing to a non-directory page - if !strings.HasSuffix(r.URL.Path, "/") { - http.Redirect(w, r, r.URL.Path+"/", - http.StatusMovedPermanently) - return - } - - // Render directory page - entries, _ := os.ReadDir(p) - var names []string - for _, entry := range entries { - if entry.IsDir() { - names = append(names, entry.Name()+"/") - } else { - names = append(names, entry.Name()) - } - } - dir := Directory{stat.Name(), names} - t.ExecuteTemplate(w, "directory.html", dir) - return - } else if strings.HasSuffix(r.URL.Path, "/") { - // Redirect URL with a trailing slash - // pointing to a non-directory page - http.Redirect(w, r, r.URL.Path[:len(r.URL.Path)-1], - http.StatusMovedPermanently) - return - } - - // Check if file is a valid ZIP archive - cbz, err := zip.OpenReader(p) - if err != nil { - http.Error(w, "invalid cbz", http.StatusNotAcceptable) - return - } - defer cbz.Close() - - // Respond with an image inside the CBZ - r.ParseForm() - if entry, isImage := r.Form["entry"]; isImage { - i, err := strconv.Atoi(entry[0]) - if err != nil || i < 0 || i >= len(cbz.File) { - http.NotFound(w, r) - return - } - image, _ := cbz.File[i].Open() - defer image.Close() - io.Copy(w, image) - return - } - - // Render archive page - entries, _ := os.ReadDir(path.Join(p, "..")) - index := find(entries, stat.Name()) - prev := "" - if index > 0 { - prev = entries[index-1].Name() - } - next := "" - if index < len(entries)-1 { - next = entries[index+1].Name() - } - t.ExecuteTemplate(w, "archive-head.html", - Archive{ stat.Name(), prev, next }) - - for i, f := range cbz.File { - image, _ := cbz.File[i].Open() - defer image.Close() - buf := make([]byte, 512) - n, _ := image.Read(buf) - mime := http.DetectContentType(buf[:n]) - if strings.HasPrefix(mime, "image/") { - t.ExecuteTemplate(w, "archive-page.html", - Page{i, f.Name}) - } - } - t.ExecuteTemplate(w, "archive-foot.html", nil) - }) - - addr := os.Getenv("PHYLACTERY_ADDRESS") - log.Println("Listening on", addr) - log.Fatal(http.ListenAndServe(addr, nil)) -} diff --git a/misc.go b/misc.go new file mode 100644 index 0000000..700256c --- /dev/null +++ b/misc.go @@ -0,0 +1,52 @@ +// Miscellaneous utilities +// Copyright (C) 2022 Nguyễn Gia Phong +// +// This file is part of Phylactery. +// +// Phylactery is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Phylactery is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Phylactery. If not, see . + +package main + +import ( + "archive/zip" + "net/http" + "os" + "strings" +) + +// Function escape ensures that question marks in path +// are not recognized as URL parameters. +func escape(name string) string { + return strings.ReplaceAll(name, "?", "%3f") +} + +// Function find searches for the directory entry of given name. +func find(entries []os.DirEntry, name string) int { + for i, entry := range entries { + if entry.Name() == name { + return i + } + } + return -1 +} + +// Function isImageFile detects if given file is an image. +func isImageFile(file *zip.File) bool { + image, _ := file.Open() + defer image.Close() + buf := make([]byte, 512) + n, _ := image.Read(buf) + mime := http.DetectContentType(buf[:n]) + return strings.HasPrefix(mime, "image/") +} diff --git a/serv.go b/serv.go new file mode 100644 index 0000000..f7e05dc --- /dev/null +++ b/serv.go @@ -0,0 +1,180 @@ +// Server entry point +// Copyright (C) 2022 Nguyễn Gia Phong +// +// This file is part of Phylactery. +// +// Phylactery is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Phylactery is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Phylactery. If not, see . + +package main + +import ( + "archive/zip" + "embed" + "encoding/xml" + "html/template" + "io" + "log" + "net/http" + "os" + "path" + "strconv" + "strings" +) + +//go:embed static/* +var static embed.FS + +//go:embed templates/*.html +var templates embed.FS + +// Type Page represents a comic page. +type Page struct { + Index int + Name string +} + +// Type Archive represents a comic book zip archive. +type Archive struct { + Title string + Prev string + Next string +} + +// Type Directory represents a library directory in file system. +type Directory struct { + Title string + Entries []string +} + +// Function main starts Phylactery serving comics from PHYLACTERY_LIBRARY +// and listening on PHYLACTERY_ADDRESS to serve at PHYLACTERY_BASE_URL. +func main() { + http.Handle("/static/", http.FileServer(http.FS(static))) + t, err := template.New("").Funcs(template.FuncMap{ + "escape": func(name string) template.URL { + return template.URL(escape(name)) + }, + }).ParseFS(templates, "templates/*.html") + if err != nil { + log.Fatal(err) + } + lib := os.Getenv("PHYLACTERY_LIBRARY") + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + p := path.Join(lib, path.Clean(r.URL.Path)) + stat, err := os.Stat(p) + if err != nil { + http.NotFound(w, r) + return + } + r.ParseForm() + + if stat.IsDir() { + // Redirect URL without a trailing slash + // pointing to a non-directory page + if !strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, r.URL.Path+"/", + http.StatusMovedPermanently) + return + } + + // Encode Atom feed + if feedFormat, isFeed := r.Form["feed"]; isFeed { + if r.URL.Path == "/" { + http.Error(w, "no feed for /", + http.StatusBadRequest) + return + } + if feedFormat[0] != "atom" { + http.Error(w, "unsupported feed format", + http.StatusUnsupportedMediaType) + return + } + w.Header().Set("Content-Type", + "application/atom+xml") + io.WriteString(w, xml.Header) + enc := xml.NewEncoder(w) + enc.Indent("", " ") + enc.Encode(synthesizeAtom(r, p, stat.ModTime())) + return + } + + // Render directory page + entries, _ := os.ReadDir(p) + var names []string + for _, entry := range entries { + if entry.IsDir() { + names = append(names, entry.Name()+"/") + } else { + names = append(names, entry.Name()) + } + } + dir := Directory{stat.Name(), names} + t.ExecuteTemplate(w, "directory.html", dir) + return + } else if strings.HasSuffix(r.URL.Path, "/") { + // Redirect URL with a trailing slash + // pointing to a non-directory page + http.Redirect(w, r, r.URL.Path[:len(r.URL.Path)-1], + http.StatusMovedPermanently) + return + } + + // Check if file is a valid ZIP archive + cbz, err := zip.OpenReader(p) + if err != nil { + http.Error(w, "invalid cbz", http.StatusNotAcceptable) + return + } + defer cbz.Close() + + // Respond with an image inside the CBZ + if entry, isImage := r.Form["entry"]; isImage { + i, err := strconv.Atoi(entry[0]) + if err != nil || i < 0 || i >= len(cbz.File) { + http.NotFound(w, r) + return + } + image, _ := cbz.File[i].Open() + defer image.Close() + io.Copy(w, image) + return + } + + // Render archive page + entries, _ := os.ReadDir(path.Join(p, "..")) + index := find(entries, stat.Name()) + prev := "" + if index > 0 { + prev = entries[index-1].Name() + } + next := "" + if index < len(entries)-1 { + next = entries[index+1].Name() + } + t.ExecuteTemplate(w, "archive-head.html", + Archive{stat.Name(), prev, next}) + + for i, f := range cbz.File { + if isImageFile(f) { + t.ExecuteTemplate(w, "archive-page.html", + Page{i, f.Name}) + } + } + t.ExecuteTemplate(w, "archive-foot.html", nil) + }) + + addr := os.Getenv("PHYLACTERY_ADDRESS") + log.Println("Listening on", addr) + log.Fatal(http.ListenAndServe(addr, nil)) +} diff --git a/templates/directory.html b/templates/directory.html index 570e526..15ee01c 100644 --- a/templates/directory.html +++ b/templates/directory.html @@ -1,5 +1,6 @@ {{template "base-head.html" -}} + {{.Title}}
-- cgit 1.4.1