|
| 1 | +package serve |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "encoding/base64" |
| 6 | + "encoding/json" |
| 7 | + "log/slog" |
| 8 | + "net/http" |
| 9 | + "os" |
| 10 | + "path" |
| 11 | + "path/filepath" |
| 12 | + "strconv" |
| 13 | + "strings" |
| 14 | + "syscall" |
| 15 | + |
| 16 | + "github.com/gorilla/mux" |
| 17 | + httprange "github.com/gotd/contrib/http_range" |
| 18 | + "github.com/pkg/errors" |
| 19 | + "github.com/readium/go-toolkit/cmd/rwp/cmd/serve/cache" |
| 20 | + "github.com/readium/go-toolkit/pkg/asset" |
| 21 | + "github.com/readium/go-toolkit/pkg/manifest" |
| 22 | + "github.com/readium/go-toolkit/pkg/pub" |
| 23 | + "github.com/readium/go-toolkit/pkg/streamer" |
| 24 | + "github.com/zeebo/xxh3" |
| 25 | +) |
| 26 | + |
| 27 | +type demoListItem struct { |
| 28 | + Filename string `json:"filename"` |
| 29 | + Path string `json:"path"` |
| 30 | +} |
| 31 | + |
| 32 | +func (s *Server) demoList(w http.ResponseWriter, req *http.Request) { |
| 33 | + fi, err := os.ReadDir(s.config.BaseDirectory) |
| 34 | + if err != nil { |
| 35 | + slog.Error("failed reading publications directory", "error", err) |
| 36 | + w.WriteHeader(500) |
| 37 | + return |
| 38 | + } |
| 39 | + files := make([]demoListItem, len(fi)) |
| 40 | + for i, f := range fi { |
| 41 | + files[i] = demoListItem{ |
| 42 | + Filename: f.Name(), |
| 43 | + Path: base64.RawURLEncoding.EncodeToString([]byte(f.Name())), |
| 44 | + } |
| 45 | + } |
| 46 | + enc := json.NewEncoder(w) |
| 47 | + enc.SetIndent("", s.config.JSONIndent) |
| 48 | + enc.Encode(files) |
| 49 | +} |
| 50 | + |
| 51 | +func (s *Server) getPublication(filename string) (*pub.Publication, error) { |
| 52 | + fpath, err := base64.RawURLEncoding.DecodeString(filename) |
| 53 | + if err != nil { |
| 54 | + return nil, err |
| 55 | + } |
| 56 | + |
| 57 | + cp := filepath.Clean(string(fpath)) |
| 58 | + dat, ok := s.lfu.Get(cp) |
| 59 | + if !ok { |
| 60 | + pub, err := streamer.New(streamer.Config{ |
| 61 | + InferA11yMetadata: s.config.InferA11yMetadata, |
| 62 | + }).Open(asset.File(filepath.Join(s.config.BaseDirectory, cp)), "") |
| 63 | + if err != nil { |
| 64 | + return nil, errors.Wrap(err, "failed opening "+cp) |
| 65 | + } |
| 66 | + |
| 67 | + // TODO: Remove this after we make links relative in the go-toolkit |
| 68 | + for i, link := range pub.Manifest.Links { |
| 69 | + pub.Manifest.Links[i] = makeRelative(link) |
| 70 | + } |
| 71 | + for i, link := range pub.Manifest.Resources { |
| 72 | + pub.Manifest.Resources[i] = makeRelative(link) |
| 73 | + } |
| 74 | + for i, link := range pub.Manifest.ReadingOrder { |
| 75 | + pub.Manifest.ReadingOrder[i] = makeRelative(link) |
| 76 | + } |
| 77 | + for i, link := range pub.Manifest.TableOfContents { |
| 78 | + pub.Manifest.TableOfContents[i] = makeRelative(link) |
| 79 | + } |
| 80 | + var makeCollectionRelative func(mp manifest.PublicationCollectionMap) |
| 81 | + makeCollectionRelative = func(mp manifest.PublicationCollectionMap) { |
| 82 | + for i := range mp { |
| 83 | + for j := range mp[i] { |
| 84 | + for k := range mp[i][j].Links { |
| 85 | + mp[i][j].Links[k] = makeRelative(mp[i][j].Links[k]) |
| 86 | + } |
| 87 | + makeCollectionRelative(mp[i][j].Subcollections) |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + makeCollectionRelative(pub.Manifest.Subcollections) |
| 92 | + |
| 93 | + // Cache the publication |
| 94 | + encPub := &cache.CachedPublication{Publication: pub} |
| 95 | + s.lfu.Set(cp, encPub) |
| 96 | + |
| 97 | + return encPub.Publication, nil |
| 98 | + } |
| 99 | + return dat.(*cache.CachedPublication).Publication, nil |
| 100 | +} |
| 101 | + |
| 102 | +func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { |
| 103 | + vars := mux.Vars(req) |
| 104 | + filename := vars["path"] |
| 105 | + |
| 106 | + // Load the publication |
| 107 | + publication, err := s.getPublication(filename) |
| 108 | + if err != nil { |
| 109 | + slog.Error("failed opening publication", "error", err) |
| 110 | + w.WriteHeader(500) |
| 111 | + return |
| 112 | + } |
| 113 | + |
| 114 | + // Create "self" link in manifest |
| 115 | + scheme := "http://" |
| 116 | + if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" { |
| 117 | + // Note: this is never going to be 100% accurate behind proxies, |
| 118 | + // but it's better than nothing for a dev server. |
| 119 | + scheme = "https://" |
| 120 | + } |
| 121 | + rPath, _ := s.router.Get("manifest").URLPath("path", vars["path"]) |
| 122 | + selfLink := &manifest.Link{ |
| 123 | + Rels: manifest.Strings{"self"}, |
| 124 | + Type: conformsToAsMimetype(publication.Manifest.Metadata.ConformsTo), |
| 125 | + Href: scheme + req.Host + rPath.String(), |
| 126 | + } |
| 127 | + |
| 128 | + // Marshal the manifest |
| 129 | + j, err := json.Marshal(publication.Manifest.ToMap(selfLink)) |
| 130 | + if err != nil { |
| 131 | + slog.Error("failed marshalling manifest JSON", "error", err) |
| 132 | + w.WriteHeader(500) |
| 133 | + return |
| 134 | + } |
| 135 | + |
| 136 | + // Indent JSON |
| 137 | + var identJSON bytes.Buffer |
| 138 | + if s.config.JSONIndent == "" { |
| 139 | + _, err = identJSON.Write(j) |
| 140 | + if err != nil { |
| 141 | + slog.Error("failed writing manifest JSON to buffer", "error", err) |
| 142 | + w.WriteHeader(500) |
| 143 | + return |
| 144 | + } |
| 145 | + } else { |
| 146 | + err = json.Indent(&identJSON, j, "", s.config.JSONIndent) |
| 147 | + if err != nil { |
| 148 | + slog.Error("failed indenting manifest JSON", "error", err) |
| 149 | + w.WriteHeader(500) |
| 150 | + return |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + // Add headers |
| 155 | + w.Header().Set("content-type", conformsToAsMimetype(publication.Manifest.Metadata.ConformsTo)+"; charset=utf-8") |
| 156 | + w.Header().Set("cache-control", "private, must-revalidate") |
| 157 | + w.Header().Set("access-control-allow-origin", "*") // TODO: provide options? |
| 158 | + |
| 159 | + // Etag based on hash of the manifest bytes |
| 160 | + etag := `"` + strconv.FormatUint(xxh3.Hash(identJSON.Bytes()), 36) + `"` |
| 161 | + w.Header().Set("Etag", etag) |
| 162 | + if match := req.Header.Get("If-None-Match"); match != "" { |
| 163 | + if strings.Contains(match, etag) { |
| 164 | + w.WriteHeader(http.StatusNotModified) |
| 165 | + return |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + // Write response body |
| 170 | + _, err = identJSON.WriteTo(w) |
| 171 | + if err != nil { |
| 172 | + slog.Error("failed writing manifest JSON to response writer", "error", err) |
| 173 | + w.WriteHeader(500) |
| 174 | + return |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { |
| 179 | + vars := mux.Vars(r) |
| 180 | + filename := vars["path"] |
| 181 | + |
| 182 | + // Load the publication |
| 183 | + publication, err := s.getPublication(filename) |
| 184 | + if err != nil { |
| 185 | + slog.Error("failed opening publication", "error", err) |
| 186 | + w.WriteHeader(500) |
| 187 | + return |
| 188 | + } |
| 189 | + |
| 190 | + // Make sure the asset exists in the publication |
| 191 | + href := path.Clean(vars["asset"]) |
| 192 | + link := publication.Find(href) |
| 193 | + if link == nil { |
| 194 | + w.WriteHeader(http.StatusNotFound) |
| 195 | + return |
| 196 | + } |
| 197 | + |
| 198 | + // Get the asset from the publication |
| 199 | + res := publication.Get(*link) |
| 200 | + defer res.Close() |
| 201 | + |
| 202 | + // Get asset length in bytes |
| 203 | + l, rerr := res.Length() |
| 204 | + if rerr != nil { |
| 205 | + w.WriteHeader(rerr.HTTPStatus()) |
| 206 | + w.Write([]byte(rerr.Error())) |
| 207 | + return |
| 208 | + } |
| 209 | + |
| 210 | + // Patch mimetype where necessary |
| 211 | + contentType := link.MediaType().String() |
| 212 | + if sub, ok := mimeSubstitutions[contentType]; ok { |
| 213 | + contentType = sub |
| 214 | + } |
| 215 | + w.Header().Set("content-type", contentType) |
| 216 | + w.Header().Set("cache-control", "private, max-age=86400, immutable") |
| 217 | + w.Header().Set("content-length", strconv.FormatInt(l, 10)) |
| 218 | + w.Header().Set("access-control-allow-origin", "*") // TODO: provide options? |
| 219 | + |
| 220 | + var start, end int64 |
| 221 | + // Range reading assets |
| 222 | + rangeHeader := r.Header.Get("range") |
| 223 | + if rangeHeader != "" { |
| 224 | + rng, err := httprange.ParseRange(rangeHeader, l) |
| 225 | + if err != nil { |
| 226 | + slog.Error("failed parsing range header", "error", err) |
| 227 | + w.WriteHeader(http.StatusLengthRequired) |
| 228 | + return |
| 229 | + } |
| 230 | + if len(rng) > 1 { |
| 231 | + slog.Error("no support for multiple read ranges") |
| 232 | + w.WriteHeader(http.StatusNotImplemented) |
| 233 | + return |
| 234 | + } |
| 235 | + if len(rng) > 0 { |
| 236 | + w.Header().Set("content-range", rng[0].ContentRange(l)) |
| 237 | + start = rng[0].Start |
| 238 | + end = start + rng[0].Length - 1 |
| 239 | + w.Header().Set("content-length", strconv.FormatInt(rng[0].Length, 10)) |
| 240 | + } |
| 241 | + } |
| 242 | + if w.Header().Get("content-range") != "" { |
| 243 | + w.WriteHeader(http.StatusPartialContent) |
| 244 | + } |
| 245 | + |
| 246 | + // Stream the asset |
| 247 | + _, rerr = res.Stream(w, start, end) |
| 248 | + if rerr != nil { |
| 249 | + if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) { |
| 250 | + // Ignore client errors |
| 251 | + return |
| 252 | + } |
| 253 | + |
| 254 | + slog.Error("failed streaming asset", "error", rerr.Error()) |
| 255 | + } |
| 256 | + |
| 257 | +} |
0 commit comments