Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c0251c5

Browse files
chocolatkeymickael-menu
andauthoredJun 17, 2024··
Upgrades to rwp utility and repo CI/CD (#91)
Co-authored-by: Mickaël Menu <[email protected]>
1 parent 7dabb92 commit c0251c5

15 files changed

+819
-54
lines changed
 

‎.github/workflows/build.yml

+5-5
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@ name: Build and Test
22

33
on:
44
push:
5-
branches: [ main ]
5+
branches: [ main, develop ]
66
pull_request:
77
branches: [ '**' ]
88

99
jobs:
1010

1111
build:
12-
runs-on: ubuntu-latest
12+
runs-on: [self-hosted, arm64]
1313
steps:
14-
- uses: actions/checkout@v2
14+
- uses: actions/checkout@v4
1515

1616
- name: Set up Go
17-
uses: actions/setup-go@v2
17+
uses: actions/setup-go@v5
1818
with:
19-
go-version: 1.21
19+
go-version: '>=1.22.0'
2020

2121
- name: Build
2222
run: go build -v ./...

‎.github/workflows/release.yml

+43-8
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,59 @@ on:
77

88
permissions:
99
contents: write
10+
packages: write
11+
12+
env:
13+
IMAGE_NAME: rwp
1014

1115
jobs:
1216
release:
13-
runs-on: ubuntu-latest
17+
runs-on: [self-hosted, arm64]
1418
steps:
15-
- uses: actions/checkout@v3
19+
- name: Checkout
20+
uses: actions/checkout@v4
1621
with:
1722
fetch-depth: 0
1823
- run: git fetch --force --tags
19-
- uses: actions/setup-go@v3
24+
- name: Set up Go
25+
uses: actions/setup-go@v5
2026
with:
21-
go-version: '>=1.21.0'
22-
cache: true
23-
- uses: goreleaser/goreleaser-action@v4
27+
go-version: '>=1.22.0'
28+
- name: Build release
29+
uses: goreleaser/goreleaser-action@v5
2430
with:
2531
distribution: goreleaser
2632
version: latest
2733
args: release --clean
2834
env:
29-
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
30-
35+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
container:
37+
runs-on: [self-hosted, arm64]
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@v4
41+
with:
42+
fetch-depth: 0
43+
- run: git fetch --force --tags
44+
- name: Set up Docker Buildx
45+
uses: docker/setup-buildx-action@v3
46+
- name: Build and push Docker image
47+
run: docker buildx build --platform=linux/amd64,linux/arm64,linux/arm/v7 . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}"
48+
- name: Log in to registry
49+
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
50+
- name: Push image
51+
run: |
52+
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
53+
# This changes all uppercase characters to lowercase.
54+
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
55+
# This strips the git ref prefix from the version.
56+
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
57+
# This strips the "v" prefix from the tag name.
58+
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
59+
# This uses the Docker `latest` tag convention.
60+
[ "$VERSION" == "main" ] && VERSION=latest
61+
echo IMAGE_ID=$IMAGE_ID
62+
echo VERSION=$VERSION
63+
docker buildx build --push \
64+
--tag $IMAGE_ID:$VERSION \
65+
--platform linux/amd64,linux/arm64,linux/arm/v7 .

‎.goreleaser.yaml

+17-9
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,37 @@
22
before:
33
hooks:
44
- go mod tidy
5+
- go generate ./...
56
builds:
67
- main: ./cmd/rwp/
8+
env:
9+
- CGO_ENABLED=0
710
id: rwp
811
binary: rwp
912
goos:
1013
- linux
1114
- windows
1215
- darwin
16+
goamd64:
17+
- v3
1318

14-
- main: ./cmd/server/
15-
id: rwp-server
16-
binary: rwp-server
17-
goos:
18-
- linux
19-
- windows
20-
- darwin
19+
# - main: ./cmd/server/
20+
# env:
21+
# - CGO_ENABLED=0
22+
# id: rwp-server
23+
# binary: rwp-server
24+
# goos:
25+
# - linux
26+
# - windows
27+
# - darwin
2128

2229
archives:
2330
- format: tar.gz
2431
# this name template makes the OS and Arch compatible with the results of uname.
32+
# Used to start with {{ .ProjectName }}
2533
name_template: >-
26-
{{ .ProjectName }}_
27-
{{- title .Os }}_
34+
rwp_
35+
{{- tolower .Os }}_
2836
{{- if eq .Arch "amd64" }}x86_64
2937
{{- else if eq .Arch "386" }}i386
3038
{{- else }}{{ .Arch }}{{ end }}

‎CHANGELOG.md

+1-10
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,4 @@ All notable changes to this project will be documented in this file.
66

77
## [Unreleased]
88

9-
### Added
10-
11-
* Add the [Media Type API](https://readium.org/architecture/proposals/001-media-type.html).
12-
* Add the file and archive fetchers of the [Fetcher API](https://readium.org/architecture/proposals/002-composite-fetcher-api.html).
13-
14-
### Changed
15-
16-
* Restructuring of the repo's folders
17-
* Removal of legacy models (LCP etc.)
18-
* Updated shared models to latest specs
9+
TODO

‎Dockerfile

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
FROM --platform=$BUILDPLATFORM golang:1-bookworm as builder
2+
ARG BUILDARCH TARGETOS TARGETARCH
3+
4+
# Install GoReleaser
5+
RUN wget --no-verbose "https://github.com/goreleaser/goreleaser/releases/download/v1.26.2/goreleaser_1.26.2_$BUILDARCH.deb"
6+
RUN dpkg -i "goreleaser_1.26.2_$BUILDARCH.deb"
7+
8+
# Create and change to the app directory.
9+
WORKDIR /app
10+
11+
# Retrieve application dependencies.
12+
# This allows the container build to reuse cached dependencies.
13+
# Expecting to copy go.mod and if present go.sum.
14+
COPY go.* ./
15+
RUN go mod download
16+
17+
# Copy local code to the container image.
18+
COPY . ./
19+
20+
# RUN git lfs pull && ls -alh publications
21+
22+
# Run goreleaser
23+
RUN --mount=type=cache,target=/root/.cache/go-build \
24+
--mount=type=cache,target=/go/pkg \
25+
GOOS=$TARGETOS GOARCH=$TARGETARCH goreleaser build --single-target --id rwp --skip=validate --snapshot --output ./rwp
26+
27+
# Run tests
28+
# FROM builder AS tester
29+
# RUN go test ./...
30+
31+
# Produces very small images
32+
FROM gcr.io/distroless/static-debian12 AS packager
33+
34+
# Extra metadata
35+
LABEL org.opencontainers.image.source="https://github.com/readium/go-toolkit"
36+
37+
# Add Fedora's mimetypes (pretty up-to-date and expansive)
38+
# since the distroless container doesn't have any. Go uses
39+
# this file as part of its mime package, and readium/go-toolkit
40+
# has a mediatype package that falls back to Go's mime
41+
# package to discover a file's mimetype when all else fails.
42+
ADD https://pagure.io/mailcap/raw/master/f/mime.types /etc/
43+
44+
# Add two demo EPUBs to the container by default
45+
ADD --chown=nonroot:nonroot https://readium-playground-files.storage.googleapis.com/demo/moby-dick.epub /srv/publications/
46+
ADD --chown=nonroot:nonroot https://readium-playground-files.storage.googleapis.com/demo/BellaOriginal3.epub /srv/publications/
47+
48+
# Copy built Go binary
49+
COPY --from=builder "/app/rwp" /opt/
50+
51+
EXPOSE 15080
52+
53+
USER nonroot:nonroot
54+
55+
ENTRYPOINT ["/opt/rwp"]
56+
CMD ["serve", "/srv/publications", "--address", "0.0.0.0"]

‎README.md

+5
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,8 @@ rwp manifest --infer-a11y=merged publication.epub | jq .metadata
5656
| `feature` | `tableOfContents` | If the publications contains a table of contents (check for the presence of a `toc` collection in RWPM) |
5757
| `feature` | `MathML` | If the publication contains any resource with MathML (check for the presence of the `contains` property where the value is `mathml` in `readingOrder` or `resources` in RWPM) |
5858
| `feature` | `synchronizedAudioText` | If the publication contains any reference to Media Overlays (TBD in RWPM) |
59+
60+
### HTTP streaming of local publications
61+
62+
`rwp serve` starts an HTTP server that serves EPUB, CBZ and other compatible formats from a given directory.
63+
A log is printed to stdout.

‎cmd/rwp/cmd/serve.go

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
11+
"log/slog"
12+
13+
"github.com/readium/go-toolkit/cmd/rwp/cmd/serve"
14+
"github.com/readium/go-toolkit/pkg/streamer"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
var debugFlag bool
19+
20+
var bindAddressFlag string
21+
22+
var bindPortFlag uint16
23+
24+
var serveCmd = &cobra.Command{
25+
Use: "serve <directory>",
26+
Short: "Start a local HTTP server, serving a specified directory of publications",
27+
Long: `Start a local HTTP server, serving a specified directory of publications.
28+
29+
This command will start an HTTP serve listening by default on 'localhost:15080',
30+
serving all compatible files (EPUB, PDF, CBZ, etc.) found in the directory
31+
as Readium Web Publications. To get started, the manifest can be accessed from
32+
'http://localhost:15080/<filename in base64url encoding without padding>/manifest.json'.
33+
This file serves as the entry point and contains metadata and links to the rest
34+
of the files that can be accessed for the publication.
35+
36+
For debugging purposes, the server also exposes a '/list.json' endpoint that
37+
returns a list of all the publications found in the directory along with their
38+
encoded paths. This will be replaced by an OPDS 2 feed in a future release.
39+
40+
Note: This server is not meant for production usage, and should not be exposed
41+
to the internet except for testing/debugging purposes.`,
42+
Args: func(cmd *cobra.Command, args []string) error {
43+
if len(args) == 0 {
44+
return errors.New("expects a directory path to serve publications from")
45+
} else if len(args) > 1 {
46+
return errors.New("accepts a directory path")
47+
}
48+
return nil
49+
},
50+
51+
SuggestFor: []string{"server"},
52+
RunE: func(cmd *cobra.Command, args []string) error {
53+
// By the time we reach this point, we know that the arguments were
54+
// properly parsed, and we don't want to show the usage if an API error
55+
// occurs.
56+
cmd.SilenceUsage = true
57+
58+
path := filepath.Clean(args[0])
59+
fi, err := os.Stat(path)
60+
if err != nil {
61+
if os.IsNotExist(err) {
62+
return fmt.Errorf("given directory %s does not exist", path)
63+
}
64+
return fmt.Errorf("failed to stat %s: %w", path, err)
65+
}
66+
if !fi.IsDir() {
67+
return fmt.Errorf("given path %s is not a directory", path)
68+
}
69+
70+
// Log level
71+
if debugFlag {
72+
slog.SetLogLoggerLevel(slog.LevelDebug)
73+
} else {
74+
slog.SetLogLoggerLevel(slog.LevelInfo)
75+
}
76+
77+
pubServer := serve.NewServer(serve.ServerConfig{
78+
Debug: debugFlag,
79+
BaseDirectory: path,
80+
JSONIndent: indentFlag,
81+
InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag),
82+
})
83+
84+
bind := fmt.Sprintf("%s:%d", bindAddressFlag, bindPortFlag)
85+
httpServer := &http.Server{
86+
ReadTimeout: 10 * time.Second,
87+
WriteTimeout: 10 * time.Second,
88+
MaxHeaderBytes: 1 << 20,
89+
Addr: bind,
90+
Handler: pubServer.Routes(),
91+
}
92+
slog.Info("Starting HTTP server", "address", "http://"+httpServer.Addr)
93+
if err := httpServer.ListenAndServe(); err != http.ErrServerClosed {
94+
slog.Error("Server stopped", "error", err)
95+
} else {
96+
slog.Info("Goodbye!")
97+
}
98+
99+
return nil
100+
},
101+
}
102+
103+
func init() {
104+
rootCmd.AddCommand(serveCmd)
105+
106+
serveCmd.Flags().StringVarP(&bindAddressFlag, "address", "a", "localhost", "Address to bind the HTTP server to")
107+
serveCmd.Flags().Uint16VarP(&bindPortFlag, "port", "p", 15080, "Port to bind the HTTP server to")
108+
serveCmd.Flags().StringVarP(&indentFlag, "indent", "i", "", "Indentation used to pretty-print JSON files")
109+
serveCmd.Flags().Var(&inferA11yFlag, "infer-a11y", "Infer accessibility metadata: no, merged, split")
110+
serveCmd.Flags().BoolVarP(&debugFlag, "debug", "d", false, "Enable debug mode")
111+
112+
}

‎cmd/rwp/cmd/serve/api.go

+257
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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+
}

‎cmd/rwp/cmd/serve/cache/local.go

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package cache
2+
3+
// Originally from https://github.com/go-redis/cache/blob/v8.4.3/local.go
4+
// Modified to store interface{} instead of []byte
5+
6+
import (
7+
"sync"
8+
"time"
9+
10+
"github.com/vmihailenco/go-tinylfu"
11+
"golang.org/x/exp/rand"
12+
)
13+
14+
type Evictable interface {
15+
OnEvict()
16+
}
17+
18+
type LocalCache interface {
19+
Set(key string, data Evictable)
20+
Get(key string) (Evictable, bool)
21+
Del(key string)
22+
}
23+
24+
type TinyLFU struct {
25+
mu sync.Mutex
26+
rand *rand.Rand
27+
lfu *tinylfu.T
28+
ttl time.Duration
29+
offset time.Duration
30+
}
31+
32+
var _ LocalCache = (*TinyLFU)(nil)
33+
34+
func NewTinyLFU(size int, ttl time.Duration) *TinyLFU {
35+
const maxOffset = 10 * time.Second
36+
37+
offset := ttl / 10
38+
if offset > maxOffset {
39+
offset = maxOffset
40+
}
41+
42+
return &TinyLFU{
43+
rand: rand.New(rand.NewSource(uint64(time.Now().UnixNano()))),
44+
lfu: tinylfu.New(size, 100000),
45+
ttl: ttl,
46+
offset: offset,
47+
}
48+
}
49+
50+
func (c *TinyLFU) UseRandomizedTTL(offset time.Duration) {
51+
c.offset = offset
52+
}
53+
54+
func (c *TinyLFU) Set(key string, b Evictable) {
55+
c.mu.Lock()
56+
defer c.mu.Unlock()
57+
58+
ttl := c.ttl
59+
if c.offset > 0 {
60+
ttl += time.Duration(c.rand.Int63n(int64(c.offset)))
61+
}
62+
63+
c.lfu.Set(&tinylfu.Item{
64+
Key: key,
65+
Value: b,
66+
ExpireAt: time.Now().Add(ttl),
67+
OnEvict: func() {
68+
b.OnEvict()
69+
},
70+
})
71+
}
72+
73+
func (c *TinyLFU) Get(key string) (Evictable, bool) {
74+
c.mu.Lock()
75+
defer c.mu.Unlock()
76+
77+
val, ok := c.lfu.Get(key)
78+
if !ok {
79+
return nil, false
80+
}
81+
82+
return val.(Evictable), true
83+
}
84+
85+
func (c *TinyLFU) Del(key string) {
86+
c.mu.Lock()
87+
defer c.mu.Unlock()
88+
89+
c.lfu.Del(key)
90+
}

‎cmd/rwp/cmd/serve/cache/pubcache.go

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package cache
2+
3+
import (
4+
"github.com/readium/go-toolkit/pkg/pub"
5+
)
6+
7+
// CachedPublication implements Evictable
8+
type CachedPublication struct {
9+
*pub.Publication
10+
}
11+
12+
func EncapsulatePublication(pub *pub.Publication) *CachedPublication {
13+
cp := &CachedPublication{pub}
14+
return cp
15+
}
16+
17+
func (cp *CachedPublication) OnEvict() {
18+
// Cleanup
19+
if cp.Publication != nil {
20+
cp.Publication.Close()
21+
}
22+
}

‎cmd/rwp/cmd/serve/helpers.go

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package serve
2+
3+
import (
4+
"strings"
5+
6+
"github.com/readium/go-toolkit/pkg/manifest"
7+
)
8+
9+
var mimeSubstitutions = map[string]string{
10+
"application/vnd.ms-opentype": "font/otf", // Not just because it's sane, but because CF will compress it!
11+
"application/vnd.readium.content+json": "application/vnd.readium.content+json; charset=utf-8", // Need utf-8 encoding
12+
}
13+
14+
var compressableMimes = []string{
15+
"application/javascript",
16+
"application/x-javascript",
17+
"image/x-icon",
18+
"text/css",
19+
"text/html",
20+
"application/xhtml+xml",
21+
"application/webpub+json",
22+
"application/divina+json",
23+
"application/vnd.readium.position-list+json",
24+
"application/vnd.readium.content+json",
25+
"application/audiobook+json",
26+
"font/ttf",
27+
"application/ttf",
28+
"application/x-ttf",
29+
"application/x-font-ttf",
30+
"font/otf",
31+
"application/otf",
32+
"application/x-otf",
33+
"application/vnd.ms-opentype",
34+
"font/opentype",
35+
"application/opentype",
36+
"application/x-opentype",
37+
"application/truetype",
38+
"application/font-woff",
39+
"font/x-woff",
40+
"application/vnd.ms-fontobject",
41+
}
42+
43+
func makeRelative(link manifest.Link) manifest.Link {
44+
link.Href = strings.TrimPrefix(link.Href, "/")
45+
for i, alt := range link.Alternates {
46+
link.Alternates[i].Href = strings.TrimPrefix(alt.Href, "/")
47+
}
48+
return link
49+
}
50+
51+
func conformsToAsMimetype(conformsTo manifest.Profiles) string {
52+
mime := "application/webpub+json"
53+
for _, profile := range conformsTo {
54+
if profile == manifest.ProfileDivina {
55+
mime = "application/divina+json"
56+
} else if profile == manifest.ProfileAudiobook {
57+
mime = "application/audiobook+json"
58+
} else {
59+
continue
60+
}
61+
break
62+
}
63+
return mime
64+
}

‎cmd/rwp/cmd/serve/router.go

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package serve
2+
3+
import (
4+
"net/http"
5+
"net/http/pprof"
6+
7+
"github.com/CAFxX/httpcompression"
8+
"github.com/gorilla/mux"
9+
)
10+
11+
func (s *Server) Routes() *mux.Router {
12+
r := mux.NewRouter()
13+
14+
r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
15+
w.WriteHeader(http.StatusOK)
16+
w.Write([]byte("OK"))
17+
})
18+
19+
if s.config.Debug {
20+
r.HandleFunc("/debug/pprof/", pprof.Index)
21+
r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
22+
r.HandleFunc("/debug/pprof/profile", pprof.Profile)
23+
r.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
24+
r.HandleFunc("/debug/pprof/trace", pprof.Trace)
25+
26+
r.Handle("/debug/pprof/allocs", pprof.Handler("allocs"))
27+
r.Handle("/debug/pprof/block", pprof.Handler("block"))
28+
r.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
29+
r.Handle("/debug/pprof/heap", pprof.Handler("heap"))
30+
r.Handle("/debug/pprof/mutex", pprof.Handler("mutex"))
31+
r.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
32+
}
33+
34+
r.HandleFunc("/list.json", s.demoList).Name("demo_list")
35+
36+
pub := r.PathPrefix("/{path}").Subrouter()
37+
// TODO: publication loading middleware with pub.Use()
38+
pub.Use(func(h http.Handler) http.Handler {
39+
adapter, _ := httpcompression.DefaultAdapter(httpcompression.ContentTypes(compressableMimes, false))
40+
return adapter(h)
41+
})
42+
pub.HandleFunc("/manifest.json", s.getManifest).Name("manifest")
43+
pub.HandleFunc("/{asset:.*}", s.getAsset).Name("asset")
44+
45+
s.router = r
46+
return r
47+
}

‎cmd/rwp/cmd/serve/server.go

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package serve
2+
3+
import (
4+
"time"
5+
6+
"github.com/gorilla/mux"
7+
"github.com/readium/go-toolkit/cmd/rwp/cmd/serve/cache"
8+
"github.com/readium/go-toolkit/pkg/streamer"
9+
)
10+
11+
type ServerConfig struct {
12+
Debug bool
13+
BaseDirectory string
14+
JSONIndent string
15+
InferA11yMetadata streamer.InferA11yMetadata
16+
}
17+
18+
type Server struct {
19+
config ServerConfig
20+
router *mux.Router
21+
lfu *cache.TinyLFU
22+
}
23+
24+
const MaxCachedPublicationAmount = 10
25+
const MaxCachedPublicationTTL = time.Second * time.Duration(600)
26+
27+
func NewServer(config ServerConfig) *Server {
28+
return &Server{
29+
config: config,
30+
lfu: cache.NewTinyLFU(MaxCachedPublicationAmount, MaxCachedPublicationTTL),
31+
}
32+
}

‎go.mod

+15-7
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ module github.com/readium/go-toolkit
33
go 1.21
44

55
require (
6+
github.com/CAFxX/httpcompression v0.0.9
67
github.com/agext/regexp v1.3.0
78
github.com/andybalholm/cascadia v1.3.2
89
github.com/deckarep/golang-set v1.7.1
910
github.com/gorilla/mux v1.7.4
11+
github.com/gotd/contrib v0.20.0
1012
github.com/opds-community/libopds2-go v0.0.0-20170628075933-9c163cf60f6e
1113
github.com/pdfcpu/pdfcpu v0.5.0
1214
github.com/pkg/errors v0.9.1
@@ -16,34 +18,40 @@ require (
1618
github.com/spf13/cobra v1.6.1
1719
github.com/spf13/pflag v1.0.5
1820
github.com/spf13/viper v1.8.1
19-
github.com/stretchr/testify v1.7.0
21+
github.com/stretchr/testify v1.9.0
2022
github.com/trimmer-io/go-xmp v1.0.0
2123
github.com/urfave/negroni v1.0.0
22-
golang.org/x/net v0.10.0
23-
golang.org/x/text v0.12.0
24+
github.com/vmihailenco/go-tinylfu v0.2.2
25+
github.com/zeebo/xxh3 v1.0.2
26+
golang.org/x/exp v0.0.0-20240529005216-23cca8864a10
27+
golang.org/x/net v0.23.0
28+
golang.org/x/text v0.14.0
2429
)
2530

2631
require (
32+
github.com/andybalholm/brotli v1.0.5 // indirect
2733
github.com/antchfx/xpath v1.2.1 // indirect
34+
github.com/cespare/xxhash/v2 v2.2.0 // indirect
2835
github.com/davecgh/go-spew v1.1.1 // indirect
2936
github.com/fsnotify/fsnotify v1.4.9 // indirect
3037
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
31-
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 // indirect
3238
github.com/hashicorp/hcl v1.0.0 // indirect
3339
github.com/hhrutter/lzw v1.0.0 // indirect
3440
github.com/hhrutter/tiff v1.0.1 // indirect
3541
github.com/inconshreveable/mousetrap v1.0.1 // indirect
42+
github.com/klauspost/compress v1.17.7 // indirect
43+
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
3644
github.com/magiconair/properties v1.8.5 // indirect
37-
github.com/mitchellh/mapstructure v1.4.1 // indirect
45+
github.com/mitchellh/mapstructure v1.5.0 // indirect
3846
github.com/pelletier/go-toml v1.9.3 // indirect
3947
github.com/pmezard/go-difflib v1.0.0 // indirect
4048
github.com/spf13/afero v1.6.0 // indirect
4149
github.com/spf13/cast v1.3.1 // indirect
4250
github.com/spf13/jwalterweatherman v1.1.0 // indirect
4351
github.com/subosito/gotenv v1.2.0 // indirect
4452
golang.org/x/image v0.11.0 // indirect
45-
golang.org/x/sys v0.8.0 // indirect
46-
gopkg.in/ini.v1 v1.62.0 // indirect
53+
golang.org/x/sys v0.19.0 // indirect
54+
gopkg.in/ini.v1 v1.67.0 // indirect
4755
gopkg.in/yaml.v2 v2.4.0 // indirect
4856
gopkg.in/yaml.v3 v3.0.1 // indirect
4957
)

‎go.sum

+53-15
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.