From 505c8f99a4740d9ae1a4f206f6056b2be9296927 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 18 Mar 2026 01:34:51 -0700 Subject: [PATCH 01/13] content API retrieval MVP --- internal/cli/serve.go | 12 +- pkg/serve/api.go | 28 ++++ pkg/serve/client/http_auth.go | 11 +- pkg/serve/content/api.go | 259 ++++++++++++++++++++++++++++++++++ pkg/serve/content/content.go | 49 +++++++ pkg/serve/server.go | 2 + 6 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 pkg/serve/content/api.go create mode 100644 pkg/serve/content/content.go diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 95fd813..9cd2c62 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -26,6 +26,7 @@ import ( "github.com/readium/cli/pkg/serve" "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/client" + "github.com/readium/cli/pkg/serve/content" "github.com/readium/go-toolkit/pkg/streamer" "github.com/readium/go-toolkit/pkg/util/url" "github.com/spf13/cobra" @@ -99,10 +100,10 @@ access to publications and prevent abuse or unauthorized access.`, for i, v := range schemeFlag { lowerScheme := url.Scheme(strings.ToLower(v)) // Accomodate for wrong capitalization switch lowerScheme { - case url.SchemeFile, url.SchemeHTTP, url.SchemeHTTPS, url.SchemeS3, url.SchemeGS: + case url.SchemeFile, url.SchemeHTTP, url.SchemeHTTPS, url.SchemeS3, url.SchemeGS, content.SchemeContent: schemes[i] = lowerScheme default: - return fmt.Errorf("invalid scheme %q, acceptable values: file, http, https, s3, gs", v) + return fmt.Errorf("invalid scheme %q, acceptable values: file, http, https, s3, gs, content", v) } } @@ -217,6 +218,12 @@ access to publications and prevent abuse or unauthorized access.`, remote.Config.Timeout = time.Duration(remoteArchiveTimeoutFlag) * time.Second remote.Config.CacheAllThreshold = int64(remoteArchiveCacheAll) + // Content fetcher + var contentFetcher *content.Fetcher + if slices.Contains(schemes, content.SchemeContent) { + contentFetcher = content.NewFetcher(remote.HTTP) + } + var authProvider auth.AuthProvider switch mode { case "base64": @@ -263,6 +270,7 @@ access to publications and prevent abuse or unauthorized access.`, JSONIndent: indentFlag, InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag), Auth: authProvider, + ContentFetcher: contentFetcher, }, remote) bind := fmt.Sprintf("%s:%d", bindAddressFlag, bindPortFlag) diff --git a/pkg/serve/api.go b/pkg/serve/api.go index 52dd51e..6184726 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -10,13 +10,17 @@ import ( "path/filepath" "slices" "strconv" + "strings" "syscall" "time" + nurl "net/url" + "github.com/gorilla/mux" httprange "github.com/gotd/contrib/http_range" "github.com/pkg/errors" "github.com/readium/cli/pkg/serve/cache" + "github.com/readium/cli/pkg/serve/content" "github.com/readium/go-toolkit/pkg/archive" "github.com/readium/go-toolkit/pkg/asset" "github.com/readium/go-toolkit/pkg/fetcher" @@ -28,6 +32,27 @@ import ( ) func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publication, bool, time.Time, error) { + var doc *content.ContentDocument + if strings.HasPrefix(filename, content.SchemeContent+":") { + if s.config.ContentFetcher == nil { + return nil, false, time.Time{}, errors.New("content API is not available") + } + cloc, err := nurl.Parse(filename) + if err != nil { + return nil, false, time.Time{}, errors.Wrap(err, "failed parsing content URL") + } + // Example: content:https://example.com/data.json --> https://example.com/data.json + if cloc.Opaque == "" { + return nil, false, time.Time{}, errors.New("content URL is missing data") + } + + doc, err = s.config.ContentFetcher.Fetch(ctx, cloc.Opaque) + if err != nil { + return nil, false, time.Time{}, errors.Wrap(err, "failed fetching content data") + } + filename, _ = doc.PublicationURL() + } + loc, err := url.URLFromString(filename) if err != nil { return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from filepath") @@ -43,6 +68,9 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ HttpClient: s.remote.HTTP, AddServiceLinks: true, } + if doc != nil { + config.OnCreatePublication = doc.Injector() + } if !s.remote.AcceptsScheme(u.Scheme()) { return nil, remote, time.Time{}, errors.New("unacceptable scheme " + u.Scheme().String()) } diff --git a/pkg/serve/client/http_auth.go b/pkg/serve/client/http_auth.go index 8e3bd11..8f8f4ae 100644 --- a/pkg/serve/client/http_auth.go +++ b/pkg/serve/client/http_auth.go @@ -4,6 +4,9 @@ import ( "fmt" "net/http" "net/url" + + "github.com/readium/cli/internal/version" + gv "github.com/readium/go-toolkit/pkg/util/version" ) type authTransport struct { @@ -16,12 +19,12 @@ func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { if !validateAgainstWhitelist(req.URL, a.Whitelist) { return nil, fmt.Errorf("request to %s is not allowed by the whitelist", req.URL) } + req2 := req.Clone(req.Context()) - if a.Authorization == "" { - return a.transport().RoundTrip(req) + req2.Header.Set("User-Agent", "readium/"+version.Version+" (go-toolkit "+gv.Version+")") + if len(a.Authorization) > 0 { + req2.Header.Set("Authorization", a.Authorization) } - req2 := req.Clone(req.Context()) - req2.Header.Set("Authorization", a.Authorization) return a.transport().RoundTrip(req2) } diff --git a/pkg/serve/content/api.go b/pkg/serve/content/api.go new file mode 100644 index 0000000..4e8fc7c --- /dev/null +++ b/pkg/serve/content/api.go @@ -0,0 +1,259 @@ +package content + +import ( + "context" + "encoding/json" + "slices" + "time" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/mediatype" + "github.com/readium/go-toolkit/pkg/pub" + "github.com/readium/go-toolkit/pkg/util/url" +) + +type ContentStatus string + +const ( + ContentStatusReady ContentStatus = "ready" + ContentStatusActive ContentStatus = "active" + ContentStatusRevoked ContentStatus = "revoked" + ContentStatusReturned ContentStatus = "returned" + ContentStatusCancelled ContentStatus = "cancelled" + ContentStatusExpired ContentStatus = "expired" +) + +type ContentRights struct { + Status ContentStatus `json:"status,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + Copy *bool `json:"copy,omitempty"` + Print *bool `json:"print,omitempty"` + Devtools *bool `json:"devtools,omitempty"` + Devices *int `json:"devices,omitempty"` +} + +// Empty returns true if all fields in the rights object are zero values. +func (r *ContentRights) Empty() bool { + return r.Status == "" && r.Expires == nil && r.Copy == nil && r.Print == nil && r.Devtools == nil && r.Devices == nil +} + +// ContentMetadata contains optional metadata fields that can override +// those in a publication manifest. All fields are pointers or slices +// so that absent fields are distinguishable from zero values. +type ContentMetadata struct { + Identifier string `json:"identifier,omitempty"` + Title *manifest.LocalizedString `json:"title,omitempty"` + Subtitle *manifest.LocalizedString `json:"subtitle,omitempty"` + SortAs *manifest.LocalizedString `json:"sortAs,omitempty"` + Type string `json:"@type,omitempty"` + ConformsTo manifest.Profiles `json:"conformsTo,omitempty"` + Accessibility *manifest.A11y `json:"accessibility,omitempty"` + Modified *time.Time `json:"modified,omitempty"` + Published *time.Time `json:"published,omitempty"` + Languages manifest.Strings `json:"language,omitempty"` + Subjects []manifest.Subject `json:"subject,omitempty"` + Authors manifest.Contributors `json:"author,omitempty"` + Translators manifest.Contributors `json:"translator,omitempty"` + Editors manifest.Contributors `json:"editor,omitempty"` + Artists manifest.Contributors `json:"artist,omitempty"` + Illustrators manifest.Contributors `json:"illustrator,omitempty"` + Letterers manifest.Contributors `json:"letterer,omitempty"` + Pencilers manifest.Contributors `json:"penciler,omitempty"` + Colorists manifest.Contributors `json:"colorist,omitempty"` + Inkers manifest.Contributors `json:"inker,omitempty"` + Narrators manifest.Contributors `json:"narrator,omitempty"` + Contributors manifest.Contributors `json:"contributor,omitempty"` + Publishers manifest.Contributors `json:"publisher,omitempty"` + Imprints manifest.Contributors `json:"imprint,omitempty"` + ReadingProgression manifest.ReadingProgression `json:"readingProgression,omitempty"` + Description string `json:"description,omitempty"` + Duration *float64 `json:"duration,omitempty"` + NumberOfPages *uint `json:"numberOfPages,omitempty"` + BelongsTo map[string]manifest.Collections `json:"belongsTo,omitempty"` +} + +type ContentDocument struct { + Links manifest.LinkList `json:"links"` + Rights *ContentRights `json:"rights,omitempty"` + Metadata *ContentMetadata `json:"metadata,omitempty"` +} + +// Merge overwrites fields in the manifest with any metadata provided +// by the ContentDocument, and appends non-publication links to the manifest. +func (d *ContentDocument) Merge(m *manifest.Manifest) { + if d.Metadata != nil { + meta := d.Metadata + if meta.Identifier != "" { + m.Metadata.Identifier = meta.Identifier + } + if meta.Title != nil { + m.Metadata.LocalizedTitle = *meta.Title + } + if meta.Subtitle != nil { + m.Metadata.LocalizedSubtitle = meta.Subtitle + } + if meta.SortAs != nil { + m.Metadata.LocalizedSortAs = meta.SortAs + } + if meta.Type != "" { + m.Metadata.Type = meta.Type + } + if len(meta.ConformsTo) > 0 { + m.Metadata.ConformsTo = meta.ConformsTo + } + if meta.Accessibility != nil { + m.Metadata.Accessibility = meta.Accessibility + } + if meta.Modified != nil { + m.Metadata.Modified = meta.Modified + } + if meta.Published != nil { + m.Metadata.Published = meta.Published + } + if len(meta.Languages) > 0 { + m.Metadata.Languages = meta.Languages + } + if len(meta.Subjects) > 0 { + m.Metadata.Subjects = meta.Subjects + } + if len(meta.Authors) > 0 { + m.Metadata.Authors = meta.Authors + } + if len(meta.Translators) > 0 { + m.Metadata.Translators = meta.Translators + } + if len(meta.Editors) > 0 { + m.Metadata.Editors = meta.Editors + } + if len(meta.Artists) > 0 { + m.Metadata.Artists = meta.Artists + } + if len(meta.Illustrators) > 0 { + m.Metadata.Illustrators = meta.Illustrators + } + if len(meta.Letterers) > 0 { + m.Metadata.Letterers = meta.Letterers + } + if len(meta.Pencilers) > 0 { + m.Metadata.Pencilers = meta.Pencilers + } + if len(meta.Colorists) > 0 { + m.Metadata.Colorists = meta.Colorists + } + if len(meta.Inkers) > 0 { + m.Metadata.Inkers = meta.Inkers + } + if len(meta.Narrators) > 0 { + m.Metadata.Narrators = meta.Narrators + } + if len(meta.Contributors) > 0 { + m.Metadata.Contributors = meta.Contributors + } + if len(meta.Publishers) > 0 { + m.Metadata.Publishers = meta.Publishers + } + if len(meta.Imprints) > 0 { + m.Metadata.Imprints = meta.Imprints + } + if meta.ReadingProgression != "" { + m.Metadata.ReadingProgression = meta.ReadingProgression + } + if meta.Description != "" { + m.Metadata.Description = meta.Description + } + if meta.Duration != nil { + m.Metadata.Duration = meta.Duration + } + if meta.NumberOfPages != nil { + m.Metadata.NumberOfPages = meta.NumberOfPages + } + if len(meta.BelongsTo) > 0 { + m.Metadata.BelongsTo = meta.BelongsTo + } + } + + // Merge non-publication links into the manifest, replacing existing links with matching rels + for _, link := range d.Links { + if slices.Contains([]string(link.Rels), "publication") { + continue + } + replaced := false + for i, existing := range m.Links { + for _, rel := range link.Rels { + if slices.Contains([]string(existing.Rels), rel) { + m.Links[i] = link + replaced = true + break + } + } + if replaced { + break + } + } + if !replaced { + m.Links = append(m.Links, link) + } + } +} + +// PublicationURL returns the href of the first link with rel "publication". +func (d *ContentDocument) PublicationURL() (string, bool) { + for _, link := range d.Links { + if slices.Contains([]string(link.Rels), "publication") { + return link.Href.String(), true + } + } + return "", false +} + +const ContentDocumentService_Name pub.ServiceName = "ContentDocumentService" + +// Injector merges the content document metadata/links into the publication, and adds the rights as a service +func (d *ContentDocument) Injector() func(builder *pub.Builder) error { + return func(builder *pub.Builder) error { + d.Merge(&builder.Manifest) + + if d.Rights == nil || d.Rights.Empty() { + return nil + } + + href, _ := url.URLFromDecodedPath("~content/rights.json") + link := manifest.Link{ + Href: manifest.NewHREF(href), + MediaType: &mediatype.JSON, + Rels: manifest.Strings{"rights"}, + } + + svc := &contentRightsService{ + link: link, + doc: d.Rights, + } + factory := pub.ServiceFactory(func(_ pub.Context, _ bool) pub.Service { + return svc + }) + builder.ServicesBuilder.Set(ContentDocumentService_Name, &factory) + return nil + } +} + +type contentRightsService struct { + link manifest.Link + doc *ContentRights +} + +func (s *contentRightsService) Links() manifest.LinkList { + return manifest.LinkList{s.link} +} + +func (s *contentRightsService) Get(_ context.Context, link manifest.Link) (fetcher.Resource, bool) { + if link.Href.String() != s.link.Href.String() { + return nil, false + } + return fetcher.NewBytesResource(s.link, func() []byte { + data, _ := json.Marshal(s.doc) + return data + }), true +} + +func (s *contentRightsService) Close() {} diff --git a/pkg/serve/content/content.go b/pkg/serve/content/content.go new file mode 100644 index 0000000..61f2172 --- /dev/null +++ b/pkg/serve/content/content.go @@ -0,0 +1,49 @@ +package content + +import ( + "context" + "fmt" + "net/http" + + "encoding/json" +) + +const SchemeContent = "content" + +type Fetcher struct { + client *http.Client +} + +func (f *Fetcher) Fetch(ctx context.Context, url string) (*ContentDocument, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("accept", "application/json") + resp, err := f.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("content API returned status %d", resp.StatusCode) + } + + var doc ContentDocument + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, fmt.Errorf("failed parsing content API response: %w", err) + } + + if _, ok := doc.PublicationURL(); !ok { + return nil, fmt.Errorf("content document has no publication link") + } + + return &doc, nil +} + +func NewFetcher(client *http.Client) *Fetcher { + return &Fetcher{ + client: client, + } +} diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 68593ac..0fa2d35 100644 --- a/pkg/serve/server.go +++ b/pkg/serve/server.go @@ -9,6 +9,7 @@ import ( "github.com/gorilla/mux" "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/cache" + "github.com/readium/cli/pkg/serve/content" "github.com/readium/go-toolkit/pkg/archive" "github.com/readium/go-toolkit/pkg/streamer" "github.com/readium/go-toolkit/pkg/util/url" @@ -46,6 +47,7 @@ type ServerConfig struct { JSONIndent string InferA11yMetadata streamer.InferA11yMetadata Auth auth.AuthProvider + ContentFetcher *content.Fetcher } type Server struct { From f96280c2fdec2945ac6ca372648c271fc9841407 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 13 Apr 2026 03:04:51 -0700 Subject: [PATCH 02/13] add ability for auth to redirect --- pkg/serve/router.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/serve/router.go b/pkg/serve/router.go index b62791c..8b6bd29 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -45,11 +45,15 @@ func (s *Server) Routes() *mux.Router { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) token := vars["path"] - newPath, status, err := s.config.Auth.Validate(token) + newPath, status, err := s.config.Auth.Validate(w, r, token) if err != nil { http.Error(w, err.Error(), status) return } + if status == http.StatusFound { + http.Redirect(w, r, newPath, http.StatusFound) + return + } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ContextPathKey, newPath))) }) }) From 06ca7f17b559b4175739e0896800e0e7199ecd3a Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 13 Apr 2026 03:11:48 -0700 Subject: [PATCH 03/13] add more rights enforcement in content doc, multiple auth headers --- go.mod | 8 +++- go.sum | 4 ++ internal/cli/serve.go | 22 +++++++-- pkg/serve/api.go | 79 ++++++++++++++++++++++++--------- pkg/serve/auth/auth.go | 4 +- pkg/serve/auth/encoded.go | 2 +- pkg/serve/auth/jwks.go | 2 +- pkg/serve/auth/jwt.go | 2 +- pkg/serve/cache/pubcache.go | 6 ++- pkg/serve/client/http_auth.go | 16 ++++--- pkg/serve/client/http_client.go | 4 +- pkg/serve/content/api.go | 68 +++++++++++++++++++++++++++- pkg/serve/content/content.go | 12 +++-- pkg/serve/router.go | 2 +- pkg/serve/server.go | 2 +- 15 files changed, 187 insertions(+), 46 deletions(-) diff --git a/go.mod b/go.mod index 75303d5..e62c31b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/readium/cli -go 1.24.2 +go 1.26 require ( cloud.google.com/go/storage v1.59.2 @@ -14,6 +14,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/mux v1.8.1 github.com/gotd/contrib v0.21.1 + github.com/maypok86/otter/v2 v2.3.0 github.com/pkg/errors v0.9.1 github.com/readium/go-toolkit v0.13.4 github.com/spf13/cobra v1.10.2 @@ -21,6 +22,7 @@ require ( github.com/zeebo/xxh3 v1.1.0 golang.org/x/sync v0.19.0 google.golang.org/api v0.264.0 + lukechampine.com/blake3 v1.4.1 ) require ( @@ -59,6 +61,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711 // indirect github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set v1.8.0 // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect @@ -82,10 +85,12 @@ require ( github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/pdfcpu/pdfcpu v0.11.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/readium/xmlquery v0.0.0-20230106230237-8f493145aef4 // indirect github.com/relvacode/iso8601 v1.7.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/trimmer-io/go-xmp v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect @@ -111,4 +116,5 @@ require ( google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index affc108..e24bc1c 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= @@ -550,6 +552,8 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 9cd2c62..815d7b6 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -47,6 +47,7 @@ var mode string var jwtSharedSecret string var jwksURL string +var defaultBondingMaxDevices uint16 // Cloud-related flags var s3EndpointFlag string @@ -58,6 +59,7 @@ var s3UsePathStyleFlag bool var httpHostWhitelistFlag []string var httpUnsafeRequestsFlag bool var httpAuthorizationFlag string +var specificHttpAuthorizationFlag []string var remoteArchiveTimeoutFlag uint32 var remoteArchiveCacheSize uint32 @@ -205,7 +207,19 @@ access to publications and prevent abuse or unauthorized access.`, } urlWhitelist[i] = parsedURL } - remote.HTTP, err = client.NewHTTPClient(httpAuthorizationFlag, urlWhitelist, httpUnsafeRequestsFlag) + hostAuthMap := map[string]string{ + "*": httpAuthorizationFlag, // Default authorization + } + for _, entry := range specificHttpAuthorizationFlag { + parts := strings.SplitN(entry, "::", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid specific HTTP authorization entry: %s, expected format 'host::authorization'", entry) + } + host := parts[0] + auth := parts[1] + hostAuthMap[host] = auth + } + remote.HTTP, err = client.NewHTTPClient(hostAuthMap, urlWhitelist, httpUnsafeRequestsFlag) if err != nil { slog.Warn("HTTP client creation failed, HTTP support will be disabled", "error", err) } @@ -219,9 +233,9 @@ access to publications and prevent abuse or unauthorized access.`, remote.Config.CacheAllThreshold = int64(remoteArchiveCacheAll) // Content fetcher - var contentFetcher *content.Fetcher + var contentFetcher content.Fetcher if slices.Contains(schemes, content.SchemeContent) { - contentFetcher = content.NewFetcher(remote.HTTP) + contentFetcher = content.NewHTTPFetcher(remote.HTTP) } var authProvider auth.AuthProvider @@ -305,6 +319,7 @@ func init() { serveCmd.Flags().StringVar(&jwtSharedSecret, "jwt-shared-secret", "", "Hex-encoded shared secret used for HS256 JWT signature validation. If omitted, but JWT auth is enabled, the secret is auto-generated and logged (debug) at runtime") serveCmd.Flags().StringVar(&jwksURL, "jwks-url", "", "URL to a JWKS (JSON Web Key Set) used for JWT signature validation when in 'jwks' mode") + serveCmd.Flags().Uint16Var(&defaultBondingMaxDevices, "default-bonding-max-devices", 2, "If not set in content rights, the default maximum number of devices to allow for bonding to a JWT in jwt-weak-bonding mode") serveCmd.Flags().StringVar(&fileDirectoryFlag, "file-directory", "", "Local directory path to serve publications from") @@ -317,6 +332,7 @@ func init() { serveCmd.Flags().StringSliceVar(&httpHostWhitelistFlag, "http-host-whitelist", []string{}, "Whitelist of HTTP hosts/paths to allow for remote HTTP requests (e.g. 'http://1.1.1.1', 'https://na1.storage.example.com/the/path'). If omitted, anything that resolves to a public IP is allowed.") serveCmd.Flags().BoolVar(&httpUnsafeRequestsFlag, "http-unsafe-requests", false, "Allow potentially unsafe HTTP requests to private IP addresses (e.g. localhost). Enable only if you completely control the requests made to the server, otherwise this can be dangerous") serveCmd.Flags().StringVar(&httpAuthorizationFlag, "http-authorization", "", "HTTP authorization header value (e.g. 'Bearer ' or 'Basic ')") + serveCmd.Flags().StringSliceVar(&specificHttpAuthorizationFlag, "http-host-authorization", []string{}, "Specific HTTP authorization header values for specific hosts/paths, in the format 'host::authorization', for example 'example.com::Bearer abc123'") serveCmd.Flags().Uint32Var(&remoteArchiveTimeoutFlag, "remote-archive-timeout", 60, "Timeout for remote archive requests (in seconds)") serveCmd.Flags().Uint32Var(&remoteArchiveCacheSize, "remote-archive-cache-size", 1024*1024, "Max size of items in an archive that can be cached (in bytes)") diff --git a/pkg/serve/api.go b/pkg/serve/api.go index 6184726..3746417 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -32,27 +32,6 @@ import ( ) func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publication, bool, time.Time, error) { - var doc *content.ContentDocument - if strings.HasPrefix(filename, content.SchemeContent+":") { - if s.config.ContentFetcher == nil { - return nil, false, time.Time{}, errors.New("content API is not available") - } - cloc, err := nurl.Parse(filename) - if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed parsing content URL") - } - // Example: content:https://example.com/data.json --> https://example.com/data.json - if cloc.Opaque == "" { - return nil, false, time.Time{}, errors.New("content URL is missing data") - } - - doc, err = s.config.ContentFetcher.Fetch(ctx, cloc.Opaque) - if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed fetching content data") - } - filename, _ = doc.PublicationURL() - } - loc, err := url.URLFromString(filename) if err != nil { return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from filepath") @@ -61,6 +40,31 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ dat, ok := s.lfu.Get(u.String()) if !ok { + var doc *content.ContentDocument + if strings.HasPrefix(filename, content.SchemeContent+":") { + if s.config.ContentFetcher == nil { + return nil, false, time.Time{}, errors.New("content API is not available") + } + cloc, err := nurl.Parse(filename) + if err != nil { + return nil, false, time.Time{}, errors.Wrap(err, "failed parsing content URL") + } + // Example: content:https://example.com/data.json --> https://example.com/data.json + if cloc.Opaque == "" { + return nil, false, time.Time{}, errors.New("content URL is missing data") + } + + doc, err = s.config.ContentFetcher.Fetch(ctx, cloc.Opaque) + if err != nil { + return nil, false, time.Time{}, errors.Wrap(err, "failed fetching content data") + } + filename, _ = doc.PublicationURL() + + if _, err := doc.Enforce(); err != nil { + return nil, false, time.Time{}, err + } + } + var pub *pub.Publication var remote bool config := streamer.Config{ @@ -122,12 +126,43 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ } // Cache the publication - encPub := cache.EncapsulatePublication(pub, remote) + encPub := cache.EncapsulatePublication(pub, doc, remote) s.lfu.Set(u.String(), encPub) return encPub.Publication, remote, encPub.CachedAt, nil } cp := dat.(*cache.CachedPublication) + + if cp.Content.Rights != nil { + refresh, err := cp.Content.Rights.Enforce() + if refresh { + cloc, err := nurl.Parse(filename) + if err != nil { + return nil, false, time.Time{}, errors.Wrap(err, "failed parsing content URL") + } + // Example: content:https://example.com/data.json --> https://example.com/data.json + if cloc.Opaque == "" { + return nil, false, time.Time{}, errors.New("content URL is missing data") + } + + var doc *content.ContentDocument + doc, err = s.config.ContentFetcher.Fetch(ctx, cloc.Opaque) + if err != nil { + return nil, false, time.Time{}, errors.Wrap(err, "failed fetching content data") + } + filename, _ = doc.PublicationURL() + + if _, err := doc.Enforce(); err != nil { + return nil, false, time.Time{}, err + } + + cp = cache.EncapsulatePublication(cp.Publication, doc, cp.Remote) + s.lfu.Set(u.String(), cp) + } else if err != nil { + return nil, false, time.Time{}, err + } + } + return cp.Publication, cp.Remote, cp.CachedAt, nil } diff --git a/pkg/serve/auth/auth.go b/pkg/serve/auth/auth.go index 01007f1..b45a6bd 100644 --- a/pkg/serve/auth/auth.go +++ b/pkg/serve/auth/auth.go @@ -1,5 +1,7 @@ package auth +import "net/http" + type AuthProvider interface { - Validate(token string) (string, int, error) + Validate(r *http.Request, token string) (string, int, error) } diff --git a/pkg/serve/auth/encoded.go b/pkg/serve/auth/encoded.go index 60fd06e..5aded49 100644 --- a/pkg/serve/auth/encoded.go +++ b/pkg/serve/auth/encoded.go @@ -8,7 +8,7 @@ import ( type B64EncodedAuthProvider struct{} -func (n *B64EncodedAuthProvider) Validate(token string) (string, int, error) { +func (n *B64EncodedAuthProvider) Validate(r *http.Request, token string) (string, int, error) { path, err := base64.RawURLEncoding.DecodeString(token) if err != nil { return "", http.StatusBadRequest, fmt.Errorf("invalid base64url path: %w", err) diff --git a/pkg/serve/auth/jwks.go b/pkg/serve/auth/jwks.go index 151b572..2bace5c 100644 --- a/pkg/serve/auth/jwks.go +++ b/pkg/serve/auth/jwks.go @@ -16,7 +16,7 @@ type JWKSAuthProvider struct { parser *jwt.Parser } -func (j *JWKSAuthProvider) Validate(token string) (string, int, error) { +func (j *JWKSAuthProvider) Validate(r *http.Request, token string) (string, int, error) { t, err := j.parser.Parse(token, j.kf.Keyfunc) if err != nil { if errors.Is(err, jwkset.ErrKeyNotFound) { diff --git a/pkg/serve/auth/jwt.go b/pkg/serve/auth/jwt.go index c98248e..0669a0b 100644 --- a/pkg/serve/auth/jwt.go +++ b/pkg/serve/auth/jwt.go @@ -13,7 +13,7 @@ type JWTAuthProvider struct { parser *jwt.Parser } -func (j *JWTAuthProvider) Validate(token string) (string, int, error) { +func (j *JWTAuthProvider) Validate(r *http.Request, token string) (string, int, error) { t, err := j.parser.Parse(token, func(t *jwt.Token) (any, error) { // We're relying on the parser to enforce method HS256 return j.sharedSecret, nil diff --git a/pkg/serve/cache/pubcache.go b/pkg/serve/cache/pubcache.go index 1401a53..0787365 100644 --- a/pkg/serve/cache/pubcache.go +++ b/pkg/serve/cache/pubcache.go @@ -3,18 +3,20 @@ package cache import ( "time" + "github.com/readium/cli/pkg/serve/content" "github.com/readium/go-toolkit/pkg/pub" ) // CachedPublication implements Evictable type CachedPublication struct { *pub.Publication + Content *content.ContentDocument Remote bool CachedAt time.Time } -func EncapsulatePublication(pub *pub.Publication, remote bool) *CachedPublication { - return &CachedPublication{pub, remote, time.Now()} +func EncapsulatePublication(pub *pub.Publication, content *content.ContentDocument, remote bool) *CachedPublication { + return &CachedPublication{pub, content, remote, time.Now()} } func (cp *CachedPublication) OnEvict() { diff --git a/pkg/serve/client/http_auth.go b/pkg/serve/client/http_auth.go index 8f8f4ae..2ccf631 100644 --- a/pkg/serve/client/http_auth.go +++ b/pkg/serve/client/http_auth.go @@ -10,7 +10,7 @@ import ( ) type authTransport struct { - Authorization string + Authorization map[string]string Whitelist []*url.URL Transport http.RoundTripper } @@ -22,9 +22,15 @@ func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { req2 := req.Clone(req.Context()) req2.Header.Set("User-Agent", "readium/"+version.Version+" (go-toolkit "+gv.Version+")") - if len(a.Authorization) > 0 { - req2.Header.Set("Authorization", a.Authorization) + + auth, ok := a.Authorization[req.URL.Host] + if !ok { + auth, ok = a.Authorization["*"] + } + if ok && len(auth) > 0 { + req2.Header.Set("Authorization", auth) } + return a.transport().RoundTrip(req2) } @@ -35,9 +41,9 @@ func (a *authTransport) transport() http.RoundTripper { return http.DefaultTransport } -func newAuthenticatedRoundTripper(auth string, whitelist []*url.URL, transport *http.Transport) http.RoundTripper { +func newAuthenticatedRoundTripper(authMap map[string]string, whitelist []*url.URL, transport *http.Transport) http.RoundTripper { return &authTransport{ - Authorization: auth, + Authorization: authMap, Whitelist: whitelist, Transport: transport, } diff --git a/pkg/serve/client/http_client.go b/pkg/serve/client/http_client.go index 9b4bdd1..9d68aa6 100644 --- a/pkg/serve/client/http_client.go +++ b/pkg/serve/client/http_client.go @@ -45,7 +45,7 @@ func safeSocketControl(network string, address string, conn syscall.RawConn) err const ClientKeepAliveTimeout = 90 // Imgproxy default var Workers = runtime.NumCPU() * 2 // Imgproxy default -func NewHTTPClient(auth string, whitelist []*url.URL, bypassSafeSocketControl bool) (*http.Client, error) { +func NewHTTPClient(authMap map[string]string, whitelist []*url.URL, bypassSafeSocketControl bool) (*http.Client, error) { safeDialer := &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, @@ -71,7 +71,7 @@ func NewHTTPClient(auth string, whitelist []*url.URL, bypassSafeSocketControl bo } return &http.Client{ - Transport: newAuthenticatedRoundTripper(auth, whitelist, safeTransport), + Transport: newAuthenticatedRoundTripper(authMap, whitelist, safeTransport), CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { // Default Go behavior diff --git a/pkg/serve/content/api.go b/pkg/serve/content/api.go index 4e8fc7c..ab74bd4 100644 --- a/pkg/serve/content/api.go +++ b/pkg/serve/content/api.go @@ -3,6 +3,7 @@ package content import ( "context" "encoding/json" + "errors" "slices" "time" @@ -24,6 +25,9 @@ const ( ContentStatusExpired ContentStatus = "expired" ) +// By default, rights should be re-fetched every hour +const DefaultRightsTTL = 60 * time.Minute + type ContentRights struct { Status ContentStatus `json:"status,omitempty"` Expires *time.Time `json:"expires,omitempty"` @@ -31,11 +35,65 @@ type ContentRights struct { Print *bool `json:"print,omitempty"` Devtools *bool `json:"devtools,omitempty"` Devices *int `json:"devices,omitempty"` + TTL *int `json:"ttl,omitempty"` + + refreshedAt time.Time } // Empty returns true if all fields in the rights object are zero values. func (r *ContentRights) Empty() bool { - return r.Status == "" && r.Expires == nil && r.Copy == nil && r.Print == nil && r.Devtools == nil && r.Devices == nil + return r == nil || (r.Status == "" && r.Expires == nil && r.Copy == nil && r.Print == nil && r.Devtools == nil && r.Devices == nil && r.TTL == nil) +} + +var ErrContentRevoked = errors.New("content access has been revoked") +var ErrContentReturned = errors.New("content has been returned") +var ErrContentCancelled = errors.New("content access has been cancelled") +var ErrContentExpired = errors.New("content access has expired") + +// Enforce checks the content rights and returns an error if access has been denied. +// A boolean is also returned indicating whether the content document should be refreshed (fetched again from source) +func (r *ContentRights) Enforce() (bool, error) { + if r.Empty() { + return false, nil + } + + switch r.Status { + case ContentStatusRevoked: + return false, ErrContentRevoked + case ContentStatusReturned: + return false, ErrContentReturned + case ContentStatusCancelled: + return false, ErrContentCancelled + case ContentStatusExpired: + return false, ErrContentExpired + } + + if r.Expires != nil { + if time.Now().After(*r.Expires) { + return false, ErrContentExpired + } + } + + if r.TTL != nil { + if time.Since(r.refreshedAt) > time.Duration(*r.TTL)*time.Second { + return true, nil + } + } else if time.Since(r.refreshedAt) > DefaultRightsTTL { + return true, nil + } + + return false, nil +} + +func (r *ContentRights) UnmarshalJSON(data []byte) error { + type alias ContentRights + var obj alias + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + *r = ContentRights(obj) + r.refreshedAt = time.Now() + return nil } // ContentMetadata contains optional metadata fields that can override @@ -237,6 +295,14 @@ func (d *ContentDocument) Injector() func(builder *pub.Builder) error { } } +func (d *ContentDocument) Enforce() (bool, error) { + if d.Rights == nil { + return false, nil + } + + return d.Rights.Enforce() +} + type contentRightsService struct { link manifest.Link doc *ContentRights diff --git a/pkg/serve/content/content.go b/pkg/serve/content/content.go index 61f2172..a798fa5 100644 --- a/pkg/serve/content/content.go +++ b/pkg/serve/content/content.go @@ -10,11 +10,15 @@ import ( const SchemeContent = "content" -type Fetcher struct { +type Fetcher interface { + Fetch(ctx context.Context, url string) (*ContentDocument, error) +} + +type HTTPFetcher struct { client *http.Client } -func (f *Fetcher) Fetch(ctx context.Context, url string) (*ContentDocument, error) { +func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*ContentDocument, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err @@ -42,8 +46,8 @@ func (f *Fetcher) Fetch(ctx context.Context, url string) (*ContentDocument, erro return &doc, nil } -func NewFetcher(client *http.Client) *Fetcher { - return &Fetcher{ +func NewHTTPFetcher(client *http.Client) Fetcher { + return &HTTPFetcher{ client: client, } } diff --git a/pkg/serve/router.go b/pkg/serve/router.go index 8b6bd29..202171b 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -45,7 +45,7 @@ func (s *Server) Routes() *mux.Router { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) token := vars["path"] - newPath, status, err := s.config.Auth.Validate(w, r, token) + newPath, status, err := s.config.Auth.Validate(r, token) if err != nil { http.Error(w, err.Error(), status) return diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 0fa2d35..6b5c892 100644 --- a/pkg/serve/server.go +++ b/pkg/serve/server.go @@ -47,7 +47,7 @@ type ServerConfig struct { JSONIndent string InferA11yMetadata streamer.InferA11yMetadata Auth auth.AuthProvider - ContentFetcher *content.Fetcher + ContentFetcher content.Fetcher } type Server struct { From cbe2a79dbcfdb2f8f7e413a4edf139758de7f8cb Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 00:45:41 -0700 Subject: [PATCH 04/13] Add auth for JWT/JWKS with bonding --- internal/cli/serve.go | 121 ++++++- pkg/serve/api.go | 116 +++++-- pkg/serve/auth/auth.go | 12 +- pkg/serve/auth/bonding.go | 327 ++++++++++++++++++ pkg/serve/auth/consts.go | 6 + pkg/serve/auth/encoded.go | 7 +- pkg/serve/auth/jwks.go | 20 +- pkg/serve/auth/jwks_bonding.go | 56 +++ pkg/serve/auth/jwt.go | 21 +- pkg/serve/auth/jwt_bonding.go | 48 +++ pkg/serve/cache/pubcache.go | 8 +- pkg/serve/router.go | 27 +- pkg/serve/server.go | 12 +- pkg/serve/{content => session}/api.go | 121 ++++--- .../content.go => session/session.go} | 16 +- 15 files changed, 760 insertions(+), 158 deletions(-) create mode 100644 pkg/serve/auth/bonding.go create mode 100644 pkg/serve/auth/consts.go create mode 100644 pkg/serve/auth/jwks_bonding.go create mode 100644 pkg/serve/auth/jwt_bonding.go rename pkg/serve/{content => session}/api.go (67%) rename pkg/serve/{content/content.go => session/session.go} (57%) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 815d7b6..811a0f4 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -26,7 +26,7 @@ import ( "github.com/readium/cli/pkg/serve" "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/client" - "github.com/readium/cli/pkg/serve/content" + "github.com/readium/cli/pkg/serve/session" "github.com/readium/go-toolkit/pkg/streamer" "github.com/readium/go-toolkit/pkg/util/url" "github.com/spf13/cobra" @@ -47,7 +47,14 @@ var mode string var jwtSharedSecret string var jwksURL string -var defaultBondingMaxDevices uint16 + +var bondingDefaultMaxDevices uint16 +var bondingMaxBondsPerSubject uint16 +var bondingMaxCacheSize uint +var bondingMinDeviceEvictionInterval time.Duration +var bondingCookiePrefix string +var bondingCookieSubfolder string +var bondingSecret string // Cloud-related flags var s3EndpointFlag string @@ -102,10 +109,10 @@ access to publications and prevent abuse or unauthorized access.`, for i, v := range schemeFlag { lowerScheme := url.Scheme(strings.ToLower(v)) // Accomodate for wrong capitalization switch lowerScheme { - case url.SchemeFile, url.SchemeHTTP, url.SchemeHTTPS, url.SchemeS3, url.SchemeGS, content.SchemeContent: + case url.SchemeFile, url.SchemeHTTP, url.SchemeHTTPS, url.SchemeS3, url.SchemeGS, session.SchemeReadingSession: schemes[i] = lowerScheme default: - return fmt.Errorf("invalid scheme %q, acceptable values: file, http, https, s3, gs, content", v) + return fmt.Errorf("invalid scheme %q, acceptable values: file, http, https, s3, gs, session", v) } } @@ -232,10 +239,10 @@ access to publications and prevent abuse or unauthorized access.`, remote.Config.Timeout = time.Duration(remoteArchiveTimeoutFlag) * time.Second remote.Config.CacheAllThreshold = int64(remoteArchiveCacheAll) - // Content fetcher - var contentFetcher content.Fetcher - if slices.Contains(schemes, content.SchemeContent) { - contentFetcher = content.NewHTTPFetcher(remote.HTTP) + // Reading session fetcher + var readingSessionFetcher session.Fetcher + if slices.Contains(schemes, session.SchemeReadingSession) { + readingSessionFetcher = session.NewHTTPFetcher(remote.HTTP) } var authProvider auth.AuthProvider @@ -274,17 +281,90 @@ access to publications and prevent abuse or unauthorized access.`, if err != nil { return fmt.Errorf("failed creating JWKS auth provider: %w", err) } + case "jwt-bonding": + if len(schemes) > 1 || !slices.Contains(schemes, session.SchemeReadingSession) { + return fmt.Errorf("in jwt-bonding mode, only the reading session scheme is allowed, and it must be enabled") + } + + var sharedSecret []byte + if jwtSharedSecret == "" { + // Auto-generate shared secret + var rawSecret [32]byte + _, err := rand.Reader.Read(rawSecret[:]) + if err != nil { + return fmt.Errorf("failed to generate random shared secret: %w", err) + } + sharedSecret = rawSecret[:] + slog.Info("Operating in HS256 JWT access mode with bonding", "secret", hex.EncodeToString(sharedSecret)) + } else { + sharedSecret, err = hex.DecodeString(jwtSharedSecret) + if err != nil { + return fmt.Errorf("failed to decode hex-encoded JWT shared secret: %w", err) + } + slog.Info("Operating in HS256 JWT access mode with bonding", "secret", "") + } + var bs []byte + if bondingSecret == "" { + var rawBondingSecret [32]byte + _, err := rand.Reader.Read(rawBondingSecret[:]) + if err != nil { + return fmt.Errorf("failed to generate random bonding secret: %w", err) + } + bs = rawBondingSecret[:] + slog.Info("No bonding secret provided, auto-generated one (not persisted, will change on restart)", "bonding_secret", hex.EncodeToString(bs)) + } else { + bs, err = hex.DecodeString(bondingSecret) + if err != nil { + return fmt.Errorf("failed to decode hex-encoded bonding secret: %w", err) + } + slog.Info("Using provided bonding secret", "bonding_secret", "") + } + + authProvider, err = auth.NewJWTBondingAuthProvider(sharedSecret, bs, bondingDefaultMaxDevices, bondingMaxBondsPerSubject, bondingMinDeviceEvictionInterval, bondingMaxCacheSize, bondingCookiePrefix, bondingCookieSubfolder) + if err != nil { + return fmt.Errorf("failed creating JWT auth provider: %w", err) + } + case "jwks-bonding": + if jwksURL == "" { + return fmt.Errorf("jwks-url must be specified in jwks-bonding mode") + } + if len(schemes) > 1 || !slices.Contains(schemes, session.SchemeReadingSession) { + return fmt.Errorf("in jwks-bonding mode, only the reading session scheme is allowed, and it must be enabled") + } + slog.Info("Operating in JWKS JWT access mode with bonding", "jwks_url", jwksURL) + + var bs []byte + if bondingSecret == "" { + var rawBondingSecret [32]byte + _, err := rand.Reader.Read(rawBondingSecret[:]) + if err != nil { + return fmt.Errorf("failed to generate random bonding secret: %w", err) + } + bs = rawBondingSecret[:] + slog.Info("No bonding secret provided, auto-generated one (not persisted, will change on restart)", "bonding_secret", hex.EncodeToString(bs)) + } else { + bs, err = hex.DecodeString(bondingSecret) + if err != nil { + return fmt.Errorf("failed to decode hex-encoded bonding secret: %w", err) + } + slog.Info("Using provided bonding secret", "bonding_secret", "") + } + + authProvider, err = auth.NewJWKSBondingAuthProvider(context.Background(), remote.HTTP, jwksURL, bs, bondingDefaultMaxDevices, bondingMaxBondsPerSubject, bondingMinDeviceEvictionInterval, bondingMaxCacheSize, bondingCookiePrefix, bondingCookieSubfolder) + if err != nil { + return fmt.Errorf("failed creating JWKS bonding auth provider: %w", err) + } default: - return fmt.Errorf("invalid access mode %q, acceptable values: base64, jwt, jwks", mode) + return fmt.Errorf("invalid access mode %q, acceptable values: base64, jwt, jwks, jwt-bonding, jwks-bonding", mode) } // Create server pubServer := serve.NewServer(serve.ServerConfig{ - Debug: debugFlag, - JSONIndent: indentFlag, - InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag), - Auth: authProvider, - ContentFetcher: contentFetcher, + Debug: debugFlag, + JSONIndent: indentFlag, + InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag), + Auth: authProvider, + ReadingSessionFetcher: readingSessionFetcher, }, remote) bind := fmt.Sprintf("%s:%d", bindAddressFlag, bindPortFlag) @@ -309,17 +389,24 @@ access to publications and prevent abuse or unauthorized access.`, func init() { rootCmd.AddCommand(serveCmd) - serveCmd.Flags().StringSliceVarP(&schemeFlag, "scheme", "s", []string{"file"}, "Scheme(s) to enable for accessing content. Acceptable values: file, http, https, s3, gs") + serveCmd.Flags().StringSliceVarP(&schemeFlag, "scheme", "s", []string{url.SchemeFile.String()}, "Scheme(s) to enable for accessing content. Acceptable values: file, http, https, s3, gs, session") serveCmd.Flags().StringVarP(&bindAddressFlag, "address", "a", "localhost", "Address to bind the HTTP server to") serveCmd.Flags().Uint16VarP(&bindPortFlag, "port", "p", 15080, "Port to bind the HTTP server to") serveCmd.Flags().StringVarP(&indentFlag, "indent", "i", "", "Indentation used to pretty-print JSON files") serveCmd.Flags().Var(&inferA11yFlag, "infer-a11y", "Infer accessibility metadata: no, merged, split") serveCmd.Flags().BoolVarP(&debugFlag, "debug", "d", false, "Enable debug mode") - serveCmd.Flags().StringVarP(&mode, "mode", "m", "base64", "Access mode: base64 (default, base64url-encoded paths), jwt (JWT auth with a shared secret), jwks (JWT auth with keys in a JWKS)") + serveCmd.Flags().StringVarP(&mode, "mode", "m", "base64", "Access mode: base64 (default, base64url-encoded paths), jwt (JWT auth with a shared secret), jwks (JWT auth with keys in a JWKS), jwt-bonding (JWT auth with device bonding), jwks-bonding (JWKS-backed JWT auth with device bonding") serveCmd.Flags().StringVar(&jwtSharedSecret, "jwt-shared-secret", "", "Hex-encoded shared secret used for HS256 JWT signature validation. If omitted, but JWT auth is enabled, the secret is auto-generated and logged (debug) at runtime") serveCmd.Flags().StringVar(&jwksURL, "jwks-url", "", "URL to a JWKS (JSON Web Key Set) used for JWT signature validation when in 'jwks' mode") - serveCmd.Flags().Uint16Var(&defaultBondingMaxDevices, "default-bonding-max-devices", 2, "If not set in content rights, the default maximum number of devices to allow for bonding to a JWT in jwt-weak-bonding mode") + + serveCmd.Flags().Uint16Var(&bondingDefaultMaxDevices, "bonding-default-max-devices", 2, "If not set in content rights, the default maximum number of devices to allow for bonding to a JWT in jwt-weak-bonding mode") + serveCmd.Flags().Uint16Var(&bondingMaxBondsPerSubject, "bonding-max-bonds-per-subject", 100, "Ceiling on bonded devices tracked per JWT subject for rights-limited publications. Caps the effective device limit to defend against sessions declaring unreasonable per-publication device counts. Publications with unlimited devices bypass the cache entirely and aren't affected. Set to 0 to disable (use only the publication's own limit)") + serveCmd.Flags().StringVar(&bondingSecret, "bonding-secret", "", "Hex-encoded secret used for bonding cookies and other data. Strongly recommended for persistence after restarts") + serveCmd.Flags().UintVar(&bondingMaxCacheSize, "bonding-max-cache-size", 10_000, "Determines the size of the cache storing session bonding information. As # of sessions (JWT `sub`) approaches this number, reading session bonds will be evicted from cache. If using `jti` in the JWT, multiply # of sessions by ~2x") + serveCmd.Flags().DurationVar(&bondingMinDeviceEvictionInterval, "bonding-min-device-eviction-interval", time.Second*30, "Minimum interval before a device can be replaced with a new one in the cache. The shorter the interval, the more abuse potential") + serveCmd.Flags().StringVar(&bondingCookiePrefix, "cookie-prefix", "bonding", "Prefix for the cookie names used for device bonding") + serveCmd.Flags().StringVar(&bondingCookieSubfolder, "cookie-subfolder", "", "Subfolder for paths of cookies set by the webserver, don't use unless it's running in a specific subfolder through a proxy") serveCmd.Flags().StringVar(&fileDirectoryFlag, "file-directory", "", "Local directory path to serve publications from") diff --git a/pkg/serve/api.go b/pkg/serve/api.go index 3746417..8e400ed 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -19,8 +19,9 @@ import ( "github.com/gorilla/mux" httprange "github.com/gotd/contrib/http_range" "github.com/pkg/errors" + "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/cache" - "github.com/readium/cli/pkg/serve/content" + "github.com/readium/cli/pkg/serve/session" "github.com/readium/go-toolkit/pkg/archive" "github.com/readium/go-toolkit/pkg/asset" "github.com/readium/go-toolkit/pkg/fetcher" @@ -31,38 +32,52 @@ import ( "github.com/zeebo/xxh3" ) -func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publication, bool, time.Time, error) { +func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, time.Time, error) { + filename, ok := ctx.Value(auth.ContextPathKey).(string) + if !ok { + return nil, false, time.Time{}, errors.New("missing publication path in context") + } + loc, err := url.URLFromString(filename) if err != nil { return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from filepath") } u := url.BaseFile.Resolve(loc).(url.AbsoluteURL) // Turn relative filepaths into file:/// URLs + cacheKey := u.String() - dat, ok := s.lfu.Get(u.String()) + dat, ok := s.lfu.Get(cacheKey) if !ok { - var doc *content.ContentDocument - if strings.HasPrefix(filename, content.SchemeContent+":") { - if s.config.ContentFetcher == nil { - return nil, false, time.Time{}, errors.New("content API is not available") + var doc *session.ReadingSessionDocument + if strings.HasPrefix(filename, session.SchemeReadingSession+":") { + if s.config.ReadingSessionFetcher == nil { + return nil, false, time.Time{}, errors.New("reading session API is not available") } cloc, err := nurl.Parse(filename) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed parsing content URL") + return nil, false, time.Time{}, errors.Wrap(err, "failed parsing reading session URL") } - // Example: content:https://example.com/data.json --> https://example.com/data.json + // Example: session:https://example.com/data.json --> https://example.com/data.json if cloc.Opaque == "" { - return nil, false, time.Time{}, errors.New("content URL is missing data") + return nil, false, time.Time{}, errors.New("reading session URL is missing data") } - doc, err = s.config.ContentFetcher.Fetch(ctx, cloc.Opaque) + doc, err = s.config.ReadingSessionFetcher.Fetch(ctx, cloc.Opaque) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed fetching content data") + return nil, false, time.Time{}, errors.Wrap(err, "failed fetching reading session data") } filename, _ = doc.PublicationURL() if _, err := doc.Enforce(); err != nil { return nil, false, time.Time{}, err } + + // Re-derive u from the resolved publication URL so the open logic + // targets the actual publication rather than the session: URL. + loc, err := url.URLFromString(filename) + if err != nil { + return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from publication URL") + } + u = url.BaseFile.Resolve(loc).(url.AbsoluteURL) } var pub *pub.Publication @@ -127,28 +142,79 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ // Cache the publication encPub := cache.EncapsulatePublication(pub, doc, remote) - s.lfu.Set(u.String(), encPub) + s.lfu.Set(cacheKey, encPub) return encPub.Publication, remote, encPub.CachedAt, nil } cp := dat.(*cache.CachedPublication) - if cp.Content.Rights != nil { - refresh, err := cp.Content.Rights.Enforce() + if cp.Session.Rights != nil { + bap, ok := s.config.Auth.(auth.BondingAuthProvider) + if ok { + bd, ok := ctx.Value(auth.BondingRecordContextKey).(auth.BondingData) + if !ok { + return nil, false, time.Time{}, errors.New("missing bonding data in context for bonding auth provider") + } + deviceCount := cp.Session.Rights.DeviceCount(bap.MaxDevices()) + if deviceCount > 0 { + // Ceiling for unreasonable per-subject device counts. + limit := deviceCount + if bap.MaxBondsPerSubject() > 0 && bap.MaxBondsPerSubject() < limit { + limit = bap.MaxBondsPerSubject() + } + + now := time.Now() + foundIdx := -1 + for i := range bd.Bonds { + if bd.Bonds[i].Device == bd.Device { + foundIdx = i + break + } + } + if foundIdx >= 0 { + bd.Bonds[foundIdx].Hash = bd.Hash + bd.Bonds[foundIdx].UpdatedAt = now + } else { + if uint16(len(bd.Bonds)) >= limit { + var newestBond time.Time + for _, b := range bd.Bonds { + if b.UpdatedAt.After(newestBond) { + newestBond = b.UpdatedAt + } + } + if time.Since(newestBond) < bap.MinDeviceEvictionInterval() { + return nil, false, time.Time{}, errors.New("device limit exceeded for this publication") + } + bd.Evict(limit - 1) + } + bd.Bonds = append(bd.Bonds, auth.AgentBond{ + Device: bd.Device, + Hash: bd.Hash, + UpdatedAt: now, + }) + } + bap.Cache().Set(bd.Key, bd.Bonds) + } + } + + refresh, err := cp.Session.Rights.Enforce() if refresh { + if s.config.ReadingSessionFetcher == nil { + return nil, false, time.Time{}, errors.New("reading session API is not available") + } cloc, err := nurl.Parse(filename) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed parsing content URL") + return nil, false, time.Time{}, errors.Wrap(err, "failed parsing reading session URL") } - // Example: content:https://example.com/data.json --> https://example.com/data.json + // Example: session:https://example.com/data.json --> https://example.com/data.json if cloc.Opaque == "" { - return nil, false, time.Time{}, errors.New("content URL is missing data") + return nil, false, time.Time{}, errors.New("reading session URL is missing data") } - var doc *content.ContentDocument - doc, err = s.config.ContentFetcher.Fetch(ctx, cloc.Opaque) + var doc *session.ReadingSessionDocument + doc, err = s.config.ReadingSessionFetcher.Fetch(ctx, cloc.Opaque) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed fetching content data") + return nil, false, time.Time{}, errors.Wrap(err, "failed fetching reading session data") } filename, _ = doc.PublicationURL() @@ -157,7 +223,7 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ } cp = cache.EncapsulatePublication(cp.Publication, doc, cp.Remote) - s.lfu.Set(u.String(), cp) + s.lfu.Set(cacheKey, cp) } else if err != nil { return nil, false, time.Time{}, err } @@ -168,10 +234,9 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { vars := mux.Vars(req) - filename := req.Context().Value(ContextPathKey).(string) // Load the publication - publication, _, cachedAt, err := s.getPublication(req.Context(), filename) + publication, _, cachedAt, err := s.getPublication(req.Context()) if err != nil { slog.Error("failed opening publication", "error", err) w.WriteHeader(500) @@ -237,10 +302,9 @@ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - filename := r.Context().Value(ContextPathKey).(string) // Load the publication - publication, remote, _, err := s.getPublication(r.Context(), filename) + publication, remote, _, err := s.getPublication(r.Context()) if err != nil { slog.Error("failed opening publication", "error", err) w.WriteHeader(500) diff --git a/pkg/serve/auth/auth.go b/pkg/serve/auth/auth.go index b45a6bd..2699b8a 100644 --- a/pkg/serve/auth/auth.go +++ b/pkg/serve/auth/auth.go @@ -1,7 +1,15 @@ package auth -import "net/http" +import ( + "net/http" +) + +type AuthError struct { + StatusCode int + Err error + RedirectPath string +} type AuthProvider interface { - Validate(r *http.Request, token string) (string, int, error) + Validate(w http.ResponseWriter, r *http.Request, token string) (*http.Request, *AuthError) } diff --git a/pkg/serve/auth/bonding.go b/pkg/serve/auth/bonding.go new file mode 100644 index 0000000..d3b1550 --- /dev/null +++ b/pkg/serve/auth/bonding.go @@ -0,0 +1,327 @@ +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/maypok86/otter/v2" + "github.com/pkg/errors" + "github.com/readium/cli/internal/version" + "lukechampine.com/blake3" +) + +type BondingEvictionProvider interface { + Evict(excess uint16) error +} + +type AgentBond struct { + Hash [32]byte + Device uuid.UUID + UpdatedAt time.Time +} + +// A BondingAuthProvider MUST set the BondingRecordContextKey in the request context. +type BondingAuthProvider interface { + MaxDevices() uint16 + MinDeviceEvictionInterval() time.Duration + MaxBondsPerSubject() uint16 + Cache() *otter.Cache[string, []AgentBond] +} + +type BondingData struct { + Hash [32]byte + Device uuid.UUID + Key string + Bonds []AgentBond +} + +func (b *BondingData) Evict(keep uint16) { + if uint16(len(b.Bonds)) <= keep { + return + } + slices.SortFunc(b.Bonds, func(x, y AgentBond) int { + return y.UpdatedAt.Compare(x.UpdatedAt) + }) + b.Bonds = b.Bonds[:keep] +} + +const bondingJwtAudience = "bonding" + +type bondingJwtClaims struct { + jwt.RegisteredClaims + DeviceHash string `json:"dh"` + AgentHash string `json:"ah"` +} + +func jwtErrorToHTTPStatus(err error) int { + if errors.Is(err, jwkset.ErrKeyNotFound) { + return http.StatusBadRequest + } else if errors.Is(err, jwt.ErrTokenMalformed) { + return http.StatusBadRequest + } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) { + return http.StatusBadRequest + } else if errors.Is(err, jwt.ErrTokenExpired) { + return http.StatusGone + } else { + return http.StatusInternalServerError + } +} + +type bondingCore struct { + bondingSecret []byte + agentHashKey [32]byte + hasherPool sync.Pool + bondingParser *jwt.Parser + cache *otter.Cache[string, []AgentBond] + defaultBondingMaxDevices uint16 + maxBondsPerSubject uint16 + minDeviceEvictionInterval time.Duration + cookiePrefix string + cookieSubfolder string + jwtPrefix string +} + +func (b *bondingCore) MaxDevices() uint16 { + return b.defaultBondingMaxDevices +} + +func (b *bondingCore) MinDeviceEvictionInterval() time.Duration { + return b.minDeviceEvictionInterval +} + +func (b *bondingCore) MaxBondsPerSubject() uint16 { + return b.maxBondsPerSubject +} + +func (b *bondingCore) Cache() *otter.Cache[string, []AgentBond] { + return b.cache +} + +func (b *bondingCore) agentHash(deviceID uuid.UUID, r *http.Request) [32]byte { + h := b.hasherPool.Get().(*blake3.Hasher) + defer b.hasherPool.Put(h) + h.Reset() + h.Write([]byte("agent|")) + h.Write(deviceID[:]) + h.Write([]byte{'|'}) + h.Write([]byte(r.Header.Get("User-Agent"))) + h.Write([]byte{'|'}) + h.Write([]byte(r.Header.Get("Accept-Language"))) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +func (b *bondingCore) deviceHash(deviceID uuid.UUID) [32]byte { + h := b.hasherPool.Get().(*blake3.Hasher) + defer b.hasherPool.Put(h) + h.Reset() + h.Write([]byte("device|")) + h.Write(deviceID[:]) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +func (b *bondingCore) setCookie(w http.ResponseWriter, name, value string, refreshTTL time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: b.cookiePrefix + "-" + name, + Value: value, + Path: "/" + b.cookieSubfolder, + MaxAge: int(refreshTTL.Seconds()), + Secure: true, + SameSite: http.SameSiteNoneMode, + HttpOnly: true, + Partitioned: true, + }) +} + +func (b *bondingCore) getOrCreateDeviceID(r *http.Request) (uuid.UUID, *AuthError) { + if c, err := r.Cookie(b.cookiePrefix + "-device"); err == nil { + if id, err := uuid.Parse(c.Value); err == nil { + return id, nil + } + } + id, err := uuid.NewV7() + if err != nil { + return uuid.Nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.Wrap(err, "failed generating UUID for device bonding")} + } + return id, nil +} + +func (b *bondingCore) validateBondingJWT(w http.ResponseWriter, r *http.Request, deviceID uuid.UUID, token string) (*http.Request, *AuthError) { + var claims bondingJwtClaims + t, err := b.bondingParser.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) { + return b.bondingSecret, nil + }) + if err != nil { + return nil, &AuthError{StatusCode: jwtErrorToHTTPStatus(err), Err: err} + } + if !t.Valid { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid JWT token")} + } + audience, err := claims.GetAudience() + if err != nil { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("failed extracting audience from JWT")} + } + if len(audience) != 1 || audience[0] != bondingJwtAudience { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("JWT audience is invalid")} + } + subject, err := claims.GetSubject() + if err != nil { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("failed extracting subject from JWT")} + } + + claimDevice, err := base64.RawURLEncoding.DecodeString(claims.DeviceHash) + if err != nil || len(claimDevice) != 32 { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid device hash in JWT")} + } + claimAgent, err := base64.RawURLEncoding.DecodeString(claims.AgentHash) + if err != nil || len(claimAgent) != 32 { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid agent hash in JWT")} + } + + curDevice := b.deviceHash(deviceID) + curAgent := b.agentHash(deviceID, r) + if !bytes.Equal(curDevice[:], claimDevice) { + return nil, &AuthError{StatusCode: http.StatusForbidden, Err: errors.New("device hash mismatch")} + } + if !bytes.Equal(curAgent[:], claimAgent) { + return nil, &AuthError{StatusCode: http.StatusForbidden, Err: errors.New("agent hash mismatch")} + } + + b.setCookie(w, "device", deviceID.String(), time.Hour*24*90) + + // Existing bonds for this subject (empty for unlimited publications, + // since api.go never writes them in that case). + bonds, _ := b.cache.GetIfPresent(subject) + bondData := BondingData{ + Key: subject, + Hash: curAgent, + Device: deviceID, + Bonds: bonds, + } + + r, err = http.NewRequestWithContext(context.WithValue(context.WithValue(r.Context(), BondingRecordContextKey, bondData), ContextPathKey, subject), "GET", subject, nil) + if err != nil { + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.Wrap(err, "failed creating new request for bonded session")} + } + return r, nil +} + +// checkAndStoreJTI enforces single-use semantics for fresh CLI JWTs that +// carry a JTI claim. Tokens without a JTI are allowed through unchanged. +func (b *bondingCore) checkAndStoreJTI(jti string) *AuthError { + if jti == "" { + return nil + } + if _, ok := b.cache.GetIfPresent("jti:" + jti); ok { + return &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("JWT token with jti claim has already been used")} + } + b.cache.Set("jti:"+jti, []AgentBond{}) + return nil +} + +func (b *bondingCore) issueBondingJWT(w http.ResponseWriter, r *http.Request, deviceID uuid.UUID, subject string) (*http.Request, *AuthError) { + b.setCookie(w, "device", deviceID.String(), time.Hour*24*90) + + curDevice := b.deviceHash(deviceID) + curAgent := b.agentHash(deviceID, r) + + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, bondingJwtClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{bondingJwtAudience}, + Issuer: "readium/" + version.Version, + Subject: subject, + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + DeviceHash: base64.RawURLEncoding.EncodeToString(curDevice[:]), + AgentHash: base64.RawURLEncoding.EncodeToString(curAgent[:]), + }) + tokStr, err := tok.SignedString(b.bondingSecret) + if err != nil { + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.Wrap(err, "failed signing bonding JWT")} + } + return nil, &AuthError{StatusCode: http.StatusFound, RedirectPath: b.jwtPrefix + tokStr} +} + +func (b *bondingCore) validateFreshJWT(w http.ResponseWriter, r *http.Request, deviceID uuid.UUID, token string, parser *jwt.Parser, keyfn jwt.Keyfunc) (*http.Request, *AuthError) { + var claims jwt.RegisteredClaims + t, err := parser.ParseWithClaims(token, &claims, keyfn) + if err != nil { + return nil, &AuthError{StatusCode: jwtErrorToHTTPStatus(err), Err: err} + } + if !t.Valid { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid JWT token")} + } + subject, err := claims.GetSubject() + if err != nil { + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("failed extracting subject from JWT")} + } + if subject == "" { + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.New("JWT subject is required")} + } + if authErr := b.checkAndStoreJTI(claims.ID); authErr != nil { + return nil, authErr + } + return b.issueBondingJWT(w, r, deviceID, subject) +} + +// newBondingCore initializes the shared bonding state. It validates the +// bonding secret, configures the cache and hasher pool, and derives the +// blake3 MAC key from the bonding secret. +func newBondingCore(bondingSecret []byte, defaultMaxDevices uint16, maxBondsPerSubject uint16, minDeviceEvictionInterval time.Duration, maxCacheSize uint, cookiePrefix, cookieSubfolder string) (*bondingCore, error) { + if len(bondingSecret) < 8 { + return nil, errors.New("length of bonding secret is less than 8 bytes") + } + if cookiePrefix == "" { + cookiePrefix = "bonding" + } + if len(cookieSubfolder) > 0 { + cookieSubfolder = strings.TrimLeft(cookieSubfolder, "/") + } + if maxCacheSize == 0 { + maxCacheSize = 10_000 + } else if maxCacheSize < 100 { + // Sanity check, and lets us hardcode the InitialCapacity below + return nil, errors.New("bonding max cache size must be at least 100") + } + if minDeviceEvictionInterval <= 10*time.Second { + // Sanity check + return nil, errors.New("bonding minimum device eviction interval must be greater than 10 seconds") + } + if maxBondsPerSubject > 0 && maxBondsPerSubject < defaultMaxDevices { + return nil, errors.New("max bonds per subject must be at least the default max devices") + } + + c := &bondingCore{ + bondingSecret: bondingSecret, + bondingParser: jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})), + cache: otter.Must(&otter.Options[string, []AgentBond]{ + MaximumSize: int(maxCacheSize), + InitialCapacity: 100, + }), + defaultBondingMaxDevices: defaultMaxDevices, + maxBondsPerSubject: maxBondsPerSubject, + cookiePrefix: cookiePrefix, + cookieSubfolder: cookieSubfolder, + jwtPrefix: cookiePrefix + ".", + minDeviceEvictionInterval: minDeviceEvictionInterval, + } + // Derive a separate 32-byte key for the blake3 MACs so we don't reuse the + // JWT signing secret directly as a hash key. + blake3.DeriveKey(c.agentHashKey[:], "readium-cli jwt-bonding agent hash v1", bondingSecret) + c.hasherPool.New = func() any { + return blake3.New(32, c.agentHashKey[:]) + } + return c, nil +} diff --git a/pkg/serve/auth/consts.go b/pkg/serve/auth/consts.go new file mode 100644 index 0000000..822d34d --- /dev/null +++ b/pkg/serve/auth/consts.go @@ -0,0 +1,6 @@ +package auth + +type ContextKey string + +const ContextPathKey ContextKey = "path" +const BondingRecordContextKey ContextKey = "bondingRecord" diff --git a/pkg/serve/auth/encoded.go b/pkg/serve/auth/encoded.go index 5aded49..d0d2f29 100644 --- a/pkg/serve/auth/encoded.go +++ b/pkg/serve/auth/encoded.go @@ -1,6 +1,7 @@ package auth import ( + "context" "encoding/base64" "fmt" "net/http" @@ -8,12 +9,12 @@ import ( type B64EncodedAuthProvider struct{} -func (n *B64EncodedAuthProvider) Validate(r *http.Request, token string) (string, int, error) { +func (n *B64EncodedAuthProvider) Validate(w http.ResponseWriter, r *http.Request, token string) (*http.Request, *AuthError) { path, err := base64.RawURLEncoding.DecodeString(token) if err != nil { - return "", http.StatusBadRequest, fmt.Errorf("invalid base64url path: %w", err) + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: fmt.Errorf("invalid base64url path: %w", err)} } - return string(path), http.StatusOK, nil + return r.WithContext(context.WithValue(r.Context(), ContextPathKey, string(path))), nil } func NewB64EncodedAuthProvider() *B64EncodedAuthProvider { diff --git a/pkg/serve/auth/jwks.go b/pkg/serve/auth/jwks.go index 2bace5c..99f492a 100644 --- a/pkg/serve/auth/jwks.go +++ b/pkg/serve/auth/jwks.go @@ -16,33 +16,33 @@ type JWKSAuthProvider struct { parser *jwt.Parser } -func (j *JWKSAuthProvider) Validate(r *http.Request, token string) (string, int, error) { +func (j *JWKSAuthProvider) Validate(w http.ResponseWriter, r *http.Request, token string) (*http.Request, *AuthError) { t, err := j.parser.Parse(token, j.kf.Keyfunc) if err != nil { if errors.Is(err, jwkset.ErrKeyNotFound) { - return "", http.StatusBadRequest, err + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: err} } else if errors.Is(err, jwt.ErrTokenMalformed) { - return "", http.StatusBadRequest, err + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: err} } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) { - return "", http.StatusBadRequest, err + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: err} } else if errors.Is(err, jwt.ErrTokenExpired) { - return "", http.StatusGone, err + return nil, &AuthError{StatusCode: http.StatusGone, Err: err} } else { - return "", http.StatusInternalServerError, err + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: err} } } if !t.Valid { - return "", http.StatusBadRequest, errors.New("invalid JWT token") + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid JWT token")} } subject, err := t.Claims.GetSubject() if err != nil { - return "", http.StatusBadRequest, errors.New("failed extracting subject from JWT") + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("failed extracting subject from JWT")} } if subject == "" { - return "", http.StatusBadRequest, errors.New("JWT subject is empty") + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("JWT subject is empty")} } - return subject, http.StatusOK, nil + return r.WithContext(context.WithValue(r.Context(), ContextPathKey, subject)), nil } func NewJWKSAuthProvider(context context.Context, client *http.Client, jwksUrl string) (*JWKSAuthProvider, error) { diff --git a/pkg/serve/auth/jwks_bonding.go b/pkg/serve/auth/jwks_bonding.go new file mode 100644 index 0000000..16e8f54 --- /dev/null +++ b/pkg/serve/auth/jwks_bonding.go @@ -0,0 +1,56 @@ +package auth + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" +) + +type JWKSBondingAuthProvider struct { + *bondingCore + kf keyfunc.Keyfunc + freshParser *jwt.Parser +} + +func (j *JWKSBondingAuthProvider) Validate(w http.ResponseWriter, r *http.Request, token string) (*http.Request, *AuthError) { + deviceID, authErr := j.getOrCreateDeviceID(r) + if authErr != nil { + return nil, authErr + } + + if strings.HasPrefix(token, j.jwtPrefix) { + return j.validateBondingJWT(w, r, deviceID, strings.TrimPrefix(token, j.jwtPrefix)) + } + + return j.validateFreshJWT(w, r, deviceID, token, j.freshParser, j.kf.Keyfunc) +} + +func NewJWKSBondingAuthProvider(ctx context.Context, client *http.Client, jwksUrl string, bondingSecret []byte, defaultMaxDevices uint16, maxBondsPerSubject uint16, minDeviceEvictionInterval time.Duration, maxCacheSize uint, cookiePrefix, cookieSubfolder string) (*JWKSBondingAuthProvider, error) { + if len(jwksUrl) == 0 { + return nil, errors.New("JWKS URL is empty") + } + + kf, err := keyfunc.NewDefaultOverrideCtx(ctx, []string{jwksUrl}, keyfunc.Override{ + Client: client, + RefreshInterval: time.Hour * 12, + }) + if err != nil { + return nil, err + } + + core, err := newBondingCore(bondingSecret, defaultMaxDevices, maxBondsPerSubject, minDeviceEvictionInterval, maxCacheSize, cookiePrefix, cookieSubfolder) + if err != nil { + return nil, err + } + + return &JWKSBondingAuthProvider{ + bondingCore: core, + kf: kf, + freshParser: jwt.NewParser(), + }, nil +} diff --git a/pkg/serve/auth/jwt.go b/pkg/serve/auth/jwt.go index 0669a0b..36679ab 100644 --- a/pkg/serve/auth/jwt.go +++ b/pkg/serve/auth/jwt.go @@ -1,6 +1,7 @@ package auth import ( + "context" "errors" "net/http" @@ -13,36 +14,36 @@ type JWTAuthProvider struct { parser *jwt.Parser } -func (j *JWTAuthProvider) Validate(r *http.Request, token string) (string, int, error) { +func (j *JWTAuthProvider) Validate(w http.ResponseWriter, r *http.Request, token string) (*http.Request, *AuthError) { t, err := j.parser.Parse(token, func(t *jwt.Token) (any, error) { // We're relying on the parser to enforce method HS256 return j.sharedSecret, nil }) if err != nil { if errors.Is(err, jwkset.ErrKeyNotFound) { - return "", http.StatusBadRequest, err + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: err} } else if errors.Is(err, jwt.ErrTokenMalformed) { - return "", http.StatusBadRequest, err + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: err} } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) { - return "", http.StatusBadRequest, err + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: err} } else if errors.Is(err, jwt.ErrTokenExpired) { - return "", http.StatusGone, err + return nil, &AuthError{StatusCode: http.StatusGone, Err: err} } else { - return "", http.StatusInternalServerError, err + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: err} } } if !t.Valid { - return "", http.StatusBadRequest, errors.New("invalid JWT token") + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid JWT token")} } subject, err := t.Claims.GetSubject() if err != nil { - return "", http.StatusBadRequest, errors.New("failed extracting subject from JWT") + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("failed extracting subject from JWT")} } if subject == "" { - return "", http.StatusBadRequest, errors.New("JWT subject is empty") + return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("JWT subject is empty")} } - return subject, http.StatusOK, nil + return r.WithContext(context.WithValue(r.Context(), ContextPathKey, subject)), nil } func NewJWTAuthProvider(sharedSecret []byte) (*JWTAuthProvider, error) { diff --git a/pkg/serve/auth/jwt_bonding.go b/pkg/serve/auth/jwt_bonding.go new file mode 100644 index 0000000..7e1d9d6 --- /dev/null +++ b/pkg/serve/auth/jwt_bonding.go @@ -0,0 +1,48 @@ +package auth + +import ( + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" +) + +type JWTBondingAuthProvider struct { + *bondingCore + sharedSecret []byte + freshParser *jwt.Parser +} + +func (j *JWTBondingAuthProvider) Validate(w http.ResponseWriter, r *http.Request, token string) (*http.Request, *AuthError) { + deviceID, authErr := j.getOrCreateDeviceID(r) + if authErr != nil { + return nil, authErr + } + + if strings.HasPrefix(token, j.jwtPrefix) { + return j.validateBondingJWT(w, r, deviceID, strings.TrimPrefix(token, j.jwtPrefix)) + } + + return j.validateFreshJWT(w, r, deviceID, token, j.freshParser, func(t *jwt.Token) (any, error) { + return j.sharedSecret, nil + }) +} + +func NewJWTBondingAuthProvider(sharedSecret []byte, bondingSecret []byte, defaultMaxDevices uint16, maxBondsPerSubject uint16, minDeviceEvictionInterval time.Duration, maxCacheSize uint, cookiePrefix, cookieSubfolder string) (*JWTBondingAuthProvider, error) { + if len(sharedSecret) < 8 { + return nil, errors.New("length of JWT shared secret is less than 8 bytes") + } + + core, err := newBondingCore(bondingSecret, defaultMaxDevices, maxBondsPerSubject, minDeviceEvictionInterval, maxCacheSize, cookiePrefix, cookieSubfolder) + if err != nil { + return nil, err + } + + return &JWTBondingAuthProvider{ + bondingCore: core, + sharedSecret: sharedSecret, + freshParser: jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})), + }, nil +} diff --git a/pkg/serve/cache/pubcache.go b/pkg/serve/cache/pubcache.go index 0787365..c89d0a1 100644 --- a/pkg/serve/cache/pubcache.go +++ b/pkg/serve/cache/pubcache.go @@ -3,20 +3,20 @@ package cache import ( "time" - "github.com/readium/cli/pkg/serve/content" + "github.com/readium/cli/pkg/serve/session" "github.com/readium/go-toolkit/pkg/pub" ) // CachedPublication implements Evictable type CachedPublication struct { *pub.Publication - Content *content.ContentDocument + Session *session.ReadingSessionDocument Remote bool CachedAt time.Time } -func EncapsulatePublication(pub *pub.Publication, content *content.ContentDocument, remote bool) *CachedPublication { - return &CachedPublication{pub, content, remote, time.Now()} +func EncapsulatePublication(pub *pub.Publication, session *session.ReadingSessionDocument, remote bool) *CachedPublication { + return &CachedPublication{pub, session, remote, time.Now()} } func (cp *CachedPublication) OnEvict() { diff --git a/pkg/serve/router.go b/pkg/serve/router.go index 202171b..9db455b 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -1,7 +1,6 @@ package serve import ( - "context" "net/http" "net/http/pprof" @@ -9,10 +8,6 @@ import ( "github.com/gorilla/mux" ) -type ContextKey string - -const ContextPathKey ContextKey = "path" - func (s *Server) Routes() *mux.Router { r := mux.NewRouter() @@ -42,19 +37,21 @@ func (s *Server) Routes() *mux.Router { return adapter(next) }) pub.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) token := vars["path"] - newPath, status, err := s.config.Auth.Validate(r, token) - if err != nil { - http.Error(w, err.Error(), status) - return - } - if status == http.StatusFound { - http.Redirect(w, r, newPath, http.StatusFound) + newRequest, aerr := s.config.Auth.Validate(w, req, token) + if aerr != nil { + if len(aerr.RedirectPath) > 0 { + ru, _ := r.Get("manifest").URLPath("path", aerr.RedirectPath) + http.Redirect(w, req, ru.String(), aerr.StatusCode) + return + } + + http.Error(w, aerr.Err.Error(), aerr.StatusCode) return } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ContextPathKey, newPath))) + next.ServeHTTP(w, newRequest) }) }) pub.HandleFunc("", func(w http.ResponseWriter, req *http.Request) { diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 6b5c892..7fef09a 100644 --- a/pkg/serve/server.go +++ b/pkg/serve/server.go @@ -9,7 +9,7 @@ import ( "github.com/gorilla/mux" "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/cache" - "github.com/readium/cli/pkg/serve/content" + "github.com/readium/cli/pkg/serve/session" "github.com/readium/go-toolkit/pkg/archive" "github.com/readium/go-toolkit/pkg/streamer" "github.com/readium/go-toolkit/pkg/util/url" @@ -43,11 +43,11 @@ func (r Remote) AcceptsScheme(scheme url.Scheme) bool { } type ServerConfig struct { - Debug bool - JSONIndent string - InferA11yMetadata streamer.InferA11yMetadata - Auth auth.AuthProvider - ContentFetcher content.Fetcher + Debug bool + JSONIndent string + InferA11yMetadata streamer.InferA11yMetadata + Auth auth.AuthProvider + ReadingSessionFetcher session.Fetcher } type Server struct { diff --git a/pkg/serve/content/api.go b/pkg/serve/session/api.go similarity index 67% rename from pkg/serve/content/api.go rename to pkg/serve/session/api.go index ab74bd4..b3b648c 100644 --- a/pkg/serve/content/api.go +++ b/pkg/serve/session/api.go @@ -1,4 +1,4 @@ -package content +package session import ( "context" @@ -14,63 +14,70 @@ import ( "github.com/readium/go-toolkit/pkg/util/url" ) -type ContentStatus string +type ReadingSessionStatus string const ( - ContentStatusReady ContentStatus = "ready" - ContentStatusActive ContentStatus = "active" - ContentStatusRevoked ContentStatus = "revoked" - ContentStatusReturned ContentStatus = "returned" - ContentStatusCancelled ContentStatus = "cancelled" - ContentStatusExpired ContentStatus = "expired" + ReadingSessionStatusReady ReadingSessionStatus = "ready" + ReadingSessionStatusActive ReadingSessionStatus = "active" + ReadingSessionStatusRevoked ReadingSessionStatus = "revoked" + ReadingSessionStatusReturned ReadingSessionStatus = "returned" + ReadingSessionStatusCancelled ReadingSessionStatus = "cancelled" + ReadingSessionStatusExpired ReadingSessionStatus = "expired" ) // By default, rights should be re-fetched every hour -const DefaultRightsTTL = 60 * time.Minute +const DefaultRightsTTL = 1 * time.Hour -type ContentRights struct { - Status ContentStatus `json:"status,omitempty"` - Expires *time.Time `json:"expires,omitempty"` - Copy *bool `json:"copy,omitempty"` - Print *bool `json:"print,omitempty"` - Devtools *bool `json:"devtools,omitempty"` - Devices *int `json:"devices,omitempty"` - TTL *int `json:"ttl,omitempty"` +type ReadingSessionRights struct { + Status ReadingSessionStatus `json:"status,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + Copy *bool `json:"copy,omitempty"` + Print *bool `json:"print,omitempty"` + Devtools *bool `json:"devtools,omitempty"` + Devices *uint16 `json:"devices,omitempty"` // Null means use default device count, 0 means no limit on devices + TTL *uint `json:"ttl,omitempty"` // Seconds until rights should be refreshed, null means use default TTL refreshedAt time.Time } // Empty returns true if all fields in the rights object are zero values. -func (r *ContentRights) Empty() bool { +func (r *ReadingSessionRights) Empty() bool { return r == nil || (r.Status == "" && r.Expires == nil && r.Copy == nil && r.Print == nil && r.Devtools == nil && r.Devices == nil && r.TTL == nil) } -var ErrContentRevoked = errors.New("content access has been revoked") -var ErrContentReturned = errors.New("content has been returned") -var ErrContentCancelled = errors.New("content access has been cancelled") -var ErrContentExpired = errors.New("content access has expired") +var ErrReadingSessionRevoked = errors.New("reading session has been revoked") +var ErrReadingSessionReturned = errors.New("reading session has been returned") +var ErrReadingSessionCancelled = errors.New("reading session has been cancelled") +var ErrReadingSessionExpired = errors.New("reading session has expired") -// Enforce checks the content rights and returns an error if access has been denied. -// A boolean is also returned indicating whether the content document should be refreshed (fetched again from source) -func (r *ContentRights) Enforce() (bool, error) { +func (r *ReadingSessionRights) DeviceCount(defaultDeviceCount uint16) uint16 { + if r.Devices == nil { + return defaultDeviceCount + } + return *r.Devices +} + +// Enforce checks the reading session rights and returns an error if access has been denied. +// A boolean is also returned indicating whether the reading session should be refreshed (fetched again from source) +func (r *ReadingSessionRights) Enforce() (bool, error) { if r.Empty() { return false, nil } switch r.Status { - case ContentStatusRevoked: - return false, ErrContentRevoked - case ContentStatusReturned: - return false, ErrContentReturned - case ContentStatusCancelled: - return false, ErrContentCancelled - case ContentStatusExpired: - return false, ErrContentExpired + case ReadingSessionStatusRevoked: + return false, ErrReadingSessionRevoked + case ReadingSessionStatusReturned: + return false, ErrReadingSessionReturned + case ReadingSessionStatusCancelled: + return false, ErrReadingSessionCancelled + case ReadingSessionStatusExpired: + return false, ErrReadingSessionExpired } if r.Expires != nil { if time.Now().After(*r.Expires) { - return false, ErrContentExpired + return false, ErrReadingSessionExpired } } @@ -85,21 +92,21 @@ func (r *ContentRights) Enforce() (bool, error) { return false, nil } -func (r *ContentRights) UnmarshalJSON(data []byte) error { - type alias ContentRights +func (r *ReadingSessionRights) UnmarshalJSON(data []byte) error { + type alias ReadingSessionRights var obj alias if err := json.Unmarshal(data, &obj); err != nil { return err } - *r = ContentRights(obj) + *r = ReadingSessionRights(obj) r.refreshedAt = time.Now() return nil } -// ContentMetadata contains optional metadata fields that can override +// ReadingSessionMetadata contains optional metadata fields that can override // those in a publication manifest. All fields are pointers or slices // so that absent fields are distinguishable from zero values. -type ContentMetadata struct { +type ReadingSessionMetadata struct { Identifier string `json:"identifier,omitempty"` Title *manifest.LocalizedString `json:"title,omitempty"` Subtitle *manifest.LocalizedString `json:"subtitle,omitempty"` @@ -131,15 +138,15 @@ type ContentMetadata struct { BelongsTo map[string]manifest.Collections `json:"belongsTo,omitempty"` } -type ContentDocument struct { - Links manifest.LinkList `json:"links"` - Rights *ContentRights `json:"rights,omitempty"` - Metadata *ContentMetadata `json:"metadata,omitempty"` +type ReadingSessionDocument struct { + Links manifest.LinkList `json:"links"` + Rights *ReadingSessionRights `json:"rights,omitempty"` + Metadata *ReadingSessionMetadata `json:"metadata,omitempty"` } // Merge overwrites fields in the manifest with any metadata provided -// by the ContentDocument, and appends non-publication links to the manifest. -func (d *ContentDocument) Merge(m *manifest.Manifest) { +// by the ReadingSessionDocument, and appends non-publication links to the manifest. +func (d *ReadingSessionDocument) Merge(m *manifest.Manifest) { if d.Metadata != nil { meta := d.Metadata if meta.Identifier != "" { @@ -256,7 +263,7 @@ func (d *ContentDocument) Merge(m *manifest.Manifest) { } // PublicationURL returns the href of the first link with rel "publication". -func (d *ContentDocument) PublicationURL() (string, bool) { +func (d *ReadingSessionDocument) PublicationURL() (string, bool) { for _, link := range d.Links { if slices.Contains([]string(link.Rels), "publication") { return link.Href.String(), true @@ -265,10 +272,10 @@ func (d *ContentDocument) PublicationURL() (string, bool) { return "", false } -const ContentDocumentService_Name pub.ServiceName = "ContentDocumentService" +const ReadingSessionDocumentService_Name pub.ServiceName = "ReadingSessionDocumentService" -// Injector merges the content document metadata/links into the publication, and adds the rights as a service -func (d *ContentDocument) Injector() func(builder *pub.Builder) error { +// Injector merges the reading session document metadata/links into the publication, and adds the rights as a service +func (d *ReadingSessionDocument) Injector() func(builder *pub.Builder) error { return func(builder *pub.Builder) error { d.Merge(&builder.Manifest) @@ -283,19 +290,19 @@ func (d *ContentDocument) Injector() func(builder *pub.Builder) error { Rels: manifest.Strings{"rights"}, } - svc := &contentRightsService{ + svc := &readingSessionRightsService{ link: link, doc: d.Rights, } factory := pub.ServiceFactory(func(_ pub.Context, _ bool) pub.Service { return svc }) - builder.ServicesBuilder.Set(ContentDocumentService_Name, &factory) + builder.ServicesBuilder.Set(ReadingSessionDocumentService_Name, &factory) return nil } } -func (d *ContentDocument) Enforce() (bool, error) { +func (d *ReadingSessionDocument) Enforce() (bool, error) { if d.Rights == nil { return false, nil } @@ -303,16 +310,16 @@ func (d *ContentDocument) Enforce() (bool, error) { return d.Rights.Enforce() } -type contentRightsService struct { +type readingSessionRightsService struct { link manifest.Link - doc *ContentRights + doc *ReadingSessionRights } -func (s *contentRightsService) Links() manifest.LinkList { +func (s *readingSessionRightsService) Links() manifest.LinkList { return manifest.LinkList{s.link} } -func (s *contentRightsService) Get(_ context.Context, link manifest.Link) (fetcher.Resource, bool) { +func (s *readingSessionRightsService) Get(_ context.Context, link manifest.Link) (fetcher.Resource, bool) { if link.Href.String() != s.link.Href.String() { return nil, false } @@ -322,4 +329,4 @@ func (s *contentRightsService) Get(_ context.Context, link manifest.Link) (fetch }), true } -func (s *contentRightsService) Close() {} +func (s *readingSessionRightsService) Close() {} diff --git a/pkg/serve/content/content.go b/pkg/serve/session/session.go similarity index 57% rename from pkg/serve/content/content.go rename to pkg/serve/session/session.go index a798fa5..4e3df05 100644 --- a/pkg/serve/content/content.go +++ b/pkg/serve/session/session.go @@ -1,4 +1,4 @@ -package content +package session import ( "context" @@ -8,17 +8,17 @@ import ( "encoding/json" ) -const SchemeContent = "content" +const SchemeReadingSession = "session" type Fetcher interface { - Fetch(ctx context.Context, url string) (*ContentDocument, error) + Fetch(ctx context.Context, url string) (*ReadingSessionDocument, error) } type HTTPFetcher struct { client *http.Client } -func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*ContentDocument, error) { +func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*ReadingSessionDocument, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err @@ -31,16 +31,16 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*ContentDocument, defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("content API returned status %d", resp.StatusCode) + return nil, fmt.Errorf("reading session API returned status %d", resp.StatusCode) } - var doc ContentDocument + var doc ReadingSessionDocument if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { - return nil, fmt.Errorf("failed parsing content API response: %w", err) + return nil, fmt.Errorf("failed parsing reading session API response: %w", err) } if _, ok := doc.PublicationURL(); !ok { - return nil, fmt.Errorf("content document has no publication link") + return nil, fmt.Errorf("reading session document has no publication link") } return &doc, nil From bca3d22d8b333ac51951e727fa69cc9b269514b3 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 02:34:49 -0700 Subject: [PATCH 05/13] Add CORS option for serve, use problem JSON responses, add http2 support --- go.mod | 17 +-- go.sum | 32 +++--- internal/cli/serve.go | 19 +++- pkg/serve/api.go | 148 ++++++++++++++----------- pkg/serve/problems/problems.go | 192 +++++++++++++++++++++++++++++++++ pkg/serve/router.go | 41 +++++-- pkg/serve/server.go | 1 + 7 files changed, 352 insertions(+), 98 deletions(-) create mode 100644 pkg/serve/problems/problems.go diff --git a/go.mod b/go.mod index e62c31b..00642c5 100644 --- a/go.mod +++ b/go.mod @@ -7,20 +7,24 @@ require ( github.com/CAFxX/httpcompression v0.0.9 github.com/MicahParks/jwkset v0.11.0 github.com/MicahParks/keyfunc/v3 v3.7.0 + github.com/airmrcr/go-problem v0.5.0 github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.7 github.com/aws/aws-sdk-go-v2/credentials v1.19.7 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/gotd/contrib v0.21.1 + github.com/klauspost/compress v1.18.6 github.com/maypok86/otter/v2 v2.3.0 github.com/pkg/errors v0.9.1 github.com/readium/go-toolkit v0.13.4 github.com/spf13/cobra v1.10.2 github.com/vmihailenco/go-tinylfu v0.2.2 github.com/zeebo/xxh3 v1.1.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 google.golang.org/api v0.264.0 lukechampine.com/blake3 v1.4.1 ) @@ -37,6 +41,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/agext/regexp v1.3.0 // indirect + github.com/airmrcr/go-optional v0.2.0 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/antchfx/xpath v1.3.4 // indirect @@ -73,7 +78,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect @@ -81,7 +85,6 @@ require ( github.com/hhrutter/tiff v1.0.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 // indirect - github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/pdfcpu/pdfcpu v0.11.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -102,13 +105,13 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/image v0.35.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/go.sum b/go.sum index e24bc1c..9d4f317 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,12 @@ github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3 github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/agext/regexp v1.3.0 h1:6+9tp+S41TU48gFNV47bX+pp1q7WahGofw6JccmsCDs= github.com/agext/regexp v1.3.0/go.mod h1:6phv1gViOJXWcTfpxOi9VMS+MaSAo+SUDf7do3ur1HA= +github.com/airmrcr/go-optional v0.2.0 h1:Yrth4FdbSx0x5BKtF7p179BxTrRcm95noxGE9L6bfNE= +github.com/airmrcr/go-optional v0.2.0/go.mod h1:UzfR9VOqz4AsnTuPUtAsx60/Wv2wutbOzQljRBhP6gM= +github.com/airmrcr/go-pointers v0.3.0 h1:JaqrBotH8lbnhFPff6sgJl409N52GPumly46W+tZ/qQ= +github.com/airmrcr/go-pointers v0.3.0/go.mod h1:3nlCMnNRr7SgQysFDYCcC4SuLLGgXM7NJYdrTtNht4I= +github.com/airmrcr/go-problem v0.5.0 h1:jhmpaIRRf391spmgV3juinYYD4+6yDMXyLDNv6k01rM= +github.com/airmrcr/go-problem v0.5.0/go.mod h1:azk7ZYu7HG/PyhVgZBX9M2zjh5CSxwfsjCKL8mtSIlE= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= @@ -198,6 +204,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gotd/contrib v0.21.1 h1:NSF+0YEnosQ34QEo2o4s6MA5YFDAor1LVvLhN1L3H1M= @@ -219,8 +227,8 @@ github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 h1:8tP9cdXzcGX2AvweVVG github.com/kettek/apng v0.0.0-20220823221153-ff692776a607/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= @@ -323,8 +331,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -383,8 +391,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -404,8 +412,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -429,8 +437,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -452,8 +460,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 811a0f4..a81aa73 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -73,6 +73,8 @@ var remoteArchiveCacheSize uint32 var remoteArchiveCacheCount uint32 var remoteArchiveCacheAll uint32 +var corsAllowedOriginsFlag []string + var serveCmd = &cobra.Command{ Use: "serve", Short: "Start a local HTTP server, serving publications locally or remotely", @@ -282,8 +284,8 @@ access to publications and prevent abuse or unauthorized access.`, return fmt.Errorf("failed creating JWKS auth provider: %w", err) } case "jwt-bonding": - if len(schemes) > 1 || !slices.Contains(schemes, session.SchemeReadingSession) { - return fmt.Errorf("in jwt-bonding mode, only the reading session scheme is allowed, and it must be enabled") + if !slices.Contains(schemes, session.SchemeReadingSession) { + return fmt.Errorf("in jwt-bonding mode, the reading session scheme must be enabled") } var sharedSecret []byte @@ -328,8 +330,8 @@ access to publications and prevent abuse or unauthorized access.`, if jwksURL == "" { return fmt.Errorf("jwks-url must be specified in jwks-bonding mode") } - if len(schemes) > 1 || !slices.Contains(schemes, session.SchemeReadingSession) { - return fmt.Errorf("in jwks-bonding mode, only the reading session scheme is allowed, and it must be enabled") + if !slices.Contains(schemes, session.SchemeReadingSession) { + return fmt.Errorf("in jwks-bonding mode, the reading session scheme must be enabled") } slog.Info("Operating in JWKS JWT access mode with bonding", "jwks_url", jwksURL) @@ -365,15 +367,20 @@ access to publications and prevent abuse or unauthorized access.`, InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag), Auth: authProvider, ReadingSessionFetcher: readingSessionFetcher, + CORSAllowedOrigins: corsAllowedOriginsFlag, }, remote) bind := fmt.Sprintf("%s:%d", bindAddressFlag, bindPortFlag) + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) httpServer := &http.Server{ ReadTimeout: 10 * time.Second, - WriteTimeout: 600 * time.Second, // 5 minutes for server to respond with resource + IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 20, Addr: bind, Handler: pubServer.Routes(), + Protocols: protocols, } slog.Info("Starting HTTP server", "address", "http://"+httpServer.Addr) if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { @@ -425,4 +432,6 @@ func init() { serveCmd.Flags().Uint32Var(&remoteArchiveCacheSize, "remote-archive-cache-size", 1024*1024, "Max size of items in an archive that can be cached (in bytes)") serveCmd.Flags().Uint32Var(&remoteArchiveCacheCount, "remote-archive-cache-count", 64, "Max number of items in an archive that can be cached") serveCmd.Flags().Uint32Var(&remoteArchiveCacheAll, "remote-archive-cache-all", 1024*1024, "Archives this size or less (in bytes) will be cached in full") + + serveCmd.Flags().StringSliceVar(&corsAllowedOriginsFlag, "cors-allowed-origin", []string{"*"}, "Allowed origins for CORS requests. Repeat the flag or comma-separate to allow multiple origins (e.g. 'https://reader.example.com'). Use '*' to allow any origin") } diff --git a/pkg/serve/api.go b/pkg/serve/api.go index 8e400ed..4658143 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -21,6 +21,7 @@ import ( "github.com/pkg/errors" "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/cache" + "github.com/readium/cli/pkg/serve/problems" "github.com/readium/cli/pkg/serve/session" "github.com/readium/go-toolkit/pkg/archive" "github.com/readium/go-toolkit/pkg/asset" @@ -35,53 +36,76 @@ import ( func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, time.Time, error) { filename, ok := ctx.Value(auth.ContextPathKey).(string) if !ok { - return nil, false, time.Time{}, errors.New("missing publication path in context") + return nil, false, time.Time{}, problems.Internal("missing publication path in context", nil) } - loc, err := url.URLFromString(filename) - if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from filepath") + isSession := strings.HasPrefix(filename, session.SchemeReadingSession+":") + bap, bapok := s.config.Auth.(auth.BondingAuthProvider) + var u url.AbsoluteURL + var cacheKey string + if isSession { + // Reading session (`session:`) URLs are not supported by the url package in + // the go-toolkit, so they are used as the raw cache key without parsing + cacheKey = filename + } else { + if bapok { + // Cannot have non-session publication when bonding auth is enabled + return nil, false, time.Time{}, problems.BadRequest.Build(). + Detail("non-session publication URLs are not allowed when bonding auth is enabled").Problem() + } + loc, err := url.URLFromString(filename) + if err != nil { + return nil, false, time.Time{}, problems.BadRequest.Build().Wrap(err). + Detail("failed creating URL from filepath").Problem() + } + u = url.BaseFile.Resolve(loc).(url.AbsoluteURL) // Turn relative filepaths into file:/// URLs + cacheKey = u.String() } - u := url.BaseFile.Resolve(loc).(url.AbsoluteURL) // Turn relative filepaths into file:/// URLs - cacheKey := u.String() dat, ok := s.lfu.Get(cacheKey) if !ok { var doc *session.ReadingSessionDocument - if strings.HasPrefix(filename, session.SchemeReadingSession+":") { + if isSession { if s.config.ReadingSessionFetcher == nil { - return nil, false, time.Time{}, errors.New("reading session API is not available") + return nil, false, time.Time{}, problems.NotImplemented.Build(). + Detail("reading session API is not available").Problem() } cloc, err := nurl.Parse(filename) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed parsing reading session URL") + return nil, false, time.Time{}, problems.BadRequest.Build().Wrap(err). + Detail("failed parsing reading session URL").Problem() } // Example: session:https://example.com/data.json --> https://example.com/data.json if cloc.Opaque == "" { - return nil, false, time.Time{}, errors.New("reading session URL is missing data") + return nil, false, time.Time{}, problems.BadRequest.Build(). + Detail("reading session URL is missing data").Problem() } doc, err = s.config.ReadingSessionFetcher.Fetch(ctx, cloc.Opaque) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed fetching reading session data") + return nil, false, time.Time{}, problems.BadGateway.Build().Wrap(err). + Detail("failed fetching reading session data").Problem() } - filename, _ = doc.PublicationURL() if _, err := doc.Enforce(); err != nil { - return nil, false, time.Time{}, err + return nil, false, time.Time{}, problems.From(err) } - // Re-derive u from the resolved publication URL so the open logic - // targets the actual publication rather than the session: URL. - loc, err := url.URLFromString(filename) + pubURL, hasPub := doc.PublicationURL() + if !hasPub { + return nil, false, time.Time{}, problems.BadGateway.Build(). + Detail("reading session document is missing a publication URL").Problem() + } + loc, err := url.URLFromString(pubURL) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from publication URL") + return nil, false, time.Time{}, problems.Internal("failed creating URL from publication URL", err) } u = url.BaseFile.Resolve(loc).(url.AbsoluteURL) } var pub *pub.Publication var remote bool + var err error config := streamer.Config{ InferA11yMetadata: s.config.InferA11yMetadata, HttpClient: s.remote.HTTP, @@ -91,52 +115,61 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti config.OnCreatePublication = doc.Injector() } if !s.remote.AcceptsScheme(u.Scheme()) { - return nil, remote, time.Time{}, errors.New("unacceptable scheme " + u.Scheme().String()) + return nil, remote, time.Time{}, problems.BadRequest.Build(). + Detailf("unacceptable scheme %q", u.Scheme().String()).Problem() } if u.IsFile() { path, err := url.FromFilepath(filepath.Join(s.remote.LocalDirectory, path.Clean(u.Path()))) if err != nil { - return nil, remote, time.Time{}, errors.Wrap(err, "failed creating URL from filepath") + return nil, remote, time.Time{}, problems.Internal("failed creating URL from filepath", err) } pub, err = streamer.New(config).Open(ctx, asset.File(path), "") if err != nil { - return nil, remote, time.Time{}, errors.Wrap(err, "failed opening "+path.String()) + return nil, remote, time.Time{}, problems.NotFound.Build().Wrap(err). + Detailf("failed opening %s", path.String()).Problem() } } else { switch u.Scheme() { case url.SchemeS3: remote = true if s.remote.S3 == nil { - return nil, remote, time.Time{}, errors.New("S3 client not configured") + return nil, remote, time.Time{}, problems.NotImplemented.Build(). + Detail("S3 client not configured").Problem() } config.ArchiveFactory = archive.NewS3ArchiveFactory(s.remote.S3, archive.NewDefaultRemoteArchiveConfig()) pub, err = streamer.New(config).Open(ctx, asset.S3(s.remote.S3, u), "") if err != nil { - return nil, remote, time.Time{}, errors.Wrap(err, "failed opening "+u.String()) + return nil, remote, time.Time{}, problems.BadGateway.Build().Wrap(err). + Detailf("failed opening %s", u.String()).Problem() } case url.SchemeGS: remote = true if s.remote.GCS == nil { - return nil, remote, time.Time{}, errors.New("GCS client not configured") + return nil, remote, time.Time{}, problems.NotImplemented.Build(). + Detail("GCS client not configured").Problem() } config.ArchiveFactory = archive.NewGCSArchiveFactory(s.remote.GCS, archive.NewDefaultRemoteArchiveConfig()) pub, err = streamer.New(config).Open(ctx, asset.GCS(s.remote.GCS, u), "") if err != nil { - return nil, remote, time.Time{}, errors.Wrap(err, "failed opening "+u.String()) + return nil, remote, time.Time{}, problems.BadGateway.Build().Wrap(err). + Detailf("failed opening %s", u.String()).Problem() } case url.SchemeHTTP, url.SchemeHTTPS: remote = true if s.remote.HTTP == nil { - return nil, remote, time.Time{}, errors.New("HTTP client not configured") + return nil, remote, time.Time{}, problems.NotImplemented.Build(). + Detail("HTTP client not configured").Problem() } config.ArchiveFactory = archive.NewHTTPArchiveFactory(s.remote.HTTP, archive.NewDefaultRemoteArchiveConfig()) pub, err = streamer.New(config).Open(ctx, asset.HTTP(s.remote.HTTP, u), "") if err != nil { - return nil, remote, time.Time{}, errors.Wrap(err, "failed opening "+u.String()) + return nil, remote, time.Time{}, problems.BadGateway.Build().Wrap(err). + Detailf("failed opening %s", u.String()).Problem() } default: - return nil, remote, time.Time{}, errors.New("unsupported scheme " + u.Scheme().String()) + return nil, remote, time.Time{}, problems.BadRequest.Build(). + Detailf("unsupported scheme %q", u.Scheme().String()).Problem() } } @@ -148,12 +181,11 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti } cp := dat.(*cache.CachedPublication) - if cp.Session.Rights != nil { - bap, ok := s.config.Auth.(auth.BondingAuthProvider) - if ok { + if cp.Session != nil && cp.Session.Rights != nil { + if bapok { bd, ok := ctx.Value(auth.BondingRecordContextKey).(auth.BondingData) if !ok { - return nil, false, time.Time{}, errors.New("missing bonding data in context for bonding auth provider") + return nil, false, time.Time{}, problems.Internal("missing bonding data in context for bonding auth provider", nil) } deviceCount := cp.Session.Rights.DeviceCount(bap.MaxDevices()) if deviceCount > 0 { @@ -183,7 +215,8 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti } } if time.Since(newestBond) < bap.MinDeviceEvictionInterval() { - return nil, false, time.Time{}, errors.New("device limit exceeded for this publication") + return nil, false, time.Time{}, problems.DeviceLimitExceeded.Build(). + Detail("device limit exceeded for this publication").Problem() } bd.Evict(limit - 1) } @@ -200,32 +233,34 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti refresh, err := cp.Session.Rights.Enforce() if refresh { if s.config.ReadingSessionFetcher == nil { - return nil, false, time.Time{}, errors.New("reading session API is not available") + return nil, false, time.Time{}, problems.NotImplemented.Build(). + Detail("reading session API is not available").Problem() } cloc, err := nurl.Parse(filename) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed parsing reading session URL") + return nil, false, time.Time{}, problems.Internal("failed parsing reading session URL", err) } // Example: session:https://example.com/data.json --> https://example.com/data.json if cloc.Opaque == "" { - return nil, false, time.Time{}, errors.New("reading session URL is missing data") + return nil, false, time.Time{}, problems.Internal("reading session URL is missing data", nil) } var doc *session.ReadingSessionDocument doc, err = s.config.ReadingSessionFetcher.Fetch(ctx, cloc.Opaque) if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed fetching reading session data") + return nil, false, time.Time{}, problems.BadGateway.Build().Wrap(err). + Detail("failed fetching reading session data").Problem() } filename, _ = doc.PublicationURL() if _, err := doc.Enforce(); err != nil { - return nil, false, time.Time{}, err + return nil, false, time.Time{}, problems.From(err) } cp = cache.EncapsulatePublication(cp.Publication, doc, cp.Remote) s.lfu.Set(cacheKey, cp) } else if err != nil { - return nil, false, time.Time{}, err + return nil, false, time.Time{}, problems.From(err) } } @@ -239,10 +274,7 @@ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { publication, _, cachedAt, err := s.getPublication(req.Context()) if err != nil { slog.Error("failed opening publication", "error", err) - w.WriteHeader(500) - if s.config.Debug { - w.Write([]byte(err.Error())) - } + problems.Write(err, w, req) return } @@ -259,10 +291,7 @@ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { selfUrl, err := url.AbsoluteURLFromString(scheme + req.Host + rPath.String()) if err != nil { slog.Error("failed creating self URL", "error", err) - w.WriteHeader(500) - if s.config.Debug { - w.Write([]byte(err.Error())) - } + problems.Write(problems.Internal("failed creating self URL", err), w, req) return } @@ -281,17 +310,13 @@ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { } if err != nil { slog.Error("failed marshalling manifest JSON", "error", err) - w.WriteHeader(500) - if s.config.Debug { - w.Write([]byte(err.Error())) - } + problems.Write(problems.Internal("failed marshalling manifest JSON", err), w, req) return } // Add headers w.Header().Set("content-type", conformsTo.String()+"; charset=utf-8") w.Header().Set("cache-control", "private, must-revalidate") - w.Header().Set("access-control-allow-origin", "*") // TODO: provide options? // Etag based on hash of the manifest bytes etag := `"` + strconv.FormatUint(xxh3.Hash(j), 36) + `"` @@ -307,10 +332,7 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { publication, remote, _, err := s.getPublication(r.Context()) if err != nil { slog.Error("failed opening publication", "error", err) - w.WriteHeader(500) - if s.config.Debug { - w.Write([]byte(err.Error())) - } + problems.Write(err, w, r) return } @@ -318,10 +340,7 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { href, err := url.URLFromDecodedPath(path.Clean(vars["asset"])) if err != nil { slog.Error("failed parsing asset path as URL", "error", err) - w.WriteHeader(400) - if s.config.Debug { - w.Write([]byte(err.Error())) - } + problems.Write(problems.BadRequest.Build().Wrap(err).Detail("failed parsing asset path as URL").Problem(), w, r) return } rawHref := href.Raw() @@ -331,7 +350,7 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { // Make sure the asset exists in the publication link := publication.LinkWithHref(href) if link == nil { - w.WriteHeader(http.StatusNotFound) + problems.Write(problems.NotFound.Build().Detailf("asset %q not found in publication", href.String()).Problem(), w, r) return } finalLink := *link @@ -348,8 +367,8 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { // Get asset length in bytes l, rerr := res.Length(r.Context()) if rerr != nil { - w.WriteHeader(rerr.HTTPStatus()) - w.Write([]byte(rerr.Error())) + slog.Error("failed reading asset length", "error", rerr) + problems.Write(problems.FromResourceError(rerr), w, r) return } @@ -364,7 +383,6 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", contentType) w.Header().Set("cache-control", "private, max-age=86400, immutable") w.Header().Set("content-length", strconv.FormatInt(l, 10)) - w.Header().Set("access-control-allow-origin", "*") // TODO: provide options? var start, end int64 // Range reading assets @@ -373,12 +391,12 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { rng, err := httprange.ParseRange(rangeHeader, l) if err != nil { slog.Error("failed parsing range header", "error", err) - w.WriteHeader(http.StatusLengthRequired) + problems.Write(problems.RangeNotSatisfiable.Build().Wrap(err).Detail("failed parsing range header").Problem(), w, r) return } if len(rng) > 1 { slog.Error("no support for multiple read ranges") - w.WriteHeader(http.StatusNotImplemented) + problems.Write(problems.NotImplemented.Build().Detail("multiple read ranges are not supported").Problem(), w, r) return } if len(rng) > 0 { diff --git a/pkg/serve/problems/problems.go b/pkg/serve/problems/problems.go new file mode 100644 index 0000000..b29afc5 --- /dev/null +++ b/pkg/serve/problems/problems.go @@ -0,0 +1,192 @@ +// Package problems defines the application/problem+json (RFC 9457) types +// returned by the serve API and helpers for converting raw errors into them. +package problems + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "syscall" + + "github.com/airmrcr/go-problem" + "github.com/readium/cli/pkg/serve/session" + "github.com/readium/go-toolkit/pkg/fetcher" +) + +// Write writes err to w as an application/problem+json response. If err already +// contains a *problem.Problem in its tree it's used directly; otherwise From +// builds one. The library's internal logger is suppressed — callers should log +// at the call site (typically via slog) if they want a log line. +// +// If writing the response body itself fails (e.g. client disconnected +// mid-write), the write error is logged here — by that point the status line +// and headers are already on the wire, so there is no way to recover. Client +// disconnect errors (EPIPE/ECONNRESET) are ignored as expected noise. +func Write(err error, w http.ResponseWriter, r *http.Request) { + werr := problem.WriteError(err, w, r, From, problem.WriteOptions{LogDisabled: true}) + if werr == nil { + return + } + if errors.Is(werr, syscall.EPIPE) || errors.Is(werr, syscall.ECONNRESET) { + return + } + slog.ErrorContext(r.Context(), "failed writing problem response", "error", werr) +} + +// genericInternalDetail is the only detail string ever sent to clients for +// InternalServerError problems. The specific cause is preserved on the +// problem's wrapped error so it reaches logs but not the response body. +const genericInternalDetail = "An internal error occurred" + +const ( + publicationServerBase = "https://readium.org/publication-server/error/" + readingSessionBase = "https://readium.org/reading-session/error/" +) + +var ( + InternalServerError = problem.Type{ + URI: publicationServerBase + "internal", + Status: http.StatusInternalServerError, + Title: "Internal Server Error", + } + BadRequest = problem.Type{ + URI: publicationServerBase + "bad-request", + Status: http.StatusBadRequest, + Title: "Bad Request", + } + NotFound = problem.Type{ + URI: publicationServerBase + "not-found", + Status: http.StatusNotFound, + Title: "Not Found", + } + Forbidden = problem.Type{ + URI: publicationServerBase + "forbidden", + Status: http.StatusForbidden, + Title: "Forbidden", + } + BadGateway = problem.Type{ + URI: publicationServerBase + "bad-gateway", + Status: http.StatusBadGateway, + Title: "Bad Gateway", + } + NotImplemented = problem.Type{ + URI: publicationServerBase + "not-implemented", + Status: http.StatusNotImplemented, + Title: "Not Implemented", + } + RangeNotSatisfiable = problem.Type{ + URI: publicationServerBase + "range-not-satisfiable", + Status: http.StatusRequestedRangeNotSatisfiable, + Title: "Range Not Satisfiable", + } + ServiceUnavailable = problem.Type{ + URI: publicationServerBase + "service-unavailable", + Status: http.StatusServiceUnavailable, + Title: "Service Unavailable", + } + Gone = problem.Type{ + URI: publicationServerBase + "gone", + Status: http.StatusGone, + Title: "Gone", + } + + ReadingSessionRevoked = problem.Type{ + URI: readingSessionBase + "revoked", + Status: http.StatusForbidden, + Title: "Reading session revoked", + } + ReadingSessionReturned = problem.Type{ + URI: readingSessionBase + "returned", + Status: http.StatusForbidden, + Title: "Reading session returned", + } + ReadingSessionCancelled = problem.Type{ + URI: readingSessionBase + "cancelled", + Status: http.StatusForbidden, + Title: "Reading session cancelled", + } + ReadingSessionExpired = problem.Type{ + URI: readingSessionBase + "expired", + Status: http.StatusGone, + Title: "Reading session expired", + } + DeviceLimitExceeded = problem.Type{ + URI: readingSessionBase + "device-limit-exceeded", + Status: http.StatusTooManyRequests, + Title: "Device limit exceeded", + } +) + +// Internal returns an InternalServerError problem. The msg and (optional) +// wrapped err are preserved on the problem's internal error chain so they +// reach logs via Problem.Error(), but the client only ever sees the generic +// detail. +func Internal(msg string, err error) *problem.Problem { + var wrapped error + switch { + case msg != "" && err != nil: + wrapped = fmt.Errorf("%s: %w", msg, err) + case msg != "": + wrapped = errors.New(msg) + case err != nil: + wrapped = err + } + b := InternalServerError.Build() + if wrapped != nil { + b = b.Wrap(wrapped) + } + return b.Detail(genericInternalDetail).Problem() +} + +// From maps a raw error to a *problem.Problem suitable for use as the fallback +// constructor passed to problem.WriteError. If the error tree already contains +// a *problem.Problem it is returned unchanged. Known sentinel errors are mapped +// to specific types; anything else becomes an InternalServerError with a +// generic detail (the original error is wrapped for logs only). +func From(err error) *problem.Problem { + if err == nil { + return nil + } + if p, ok := problem.As(err); ok { + return p + } + + switch { + case errors.Is(err, session.ErrReadingSessionRevoked): + return ReadingSessionRevoked.Build().Wrap(err).Detail(err.Error()).Problem() + case errors.Is(err, session.ErrReadingSessionReturned): + return ReadingSessionReturned.Build().Wrap(err).Detail(err.Error()).Problem() + case errors.Is(err, session.ErrReadingSessionCancelled): + return ReadingSessionCancelled.Build().Wrap(err).Detail(err.Error()).Problem() + case errors.Is(err, session.ErrReadingSessionExpired): + return ReadingSessionExpired.Build().Wrap(err).Detail(err.Error()).Problem() + } + + return Internal("", err) +} + +// FromResourceError converts a fetcher.ResourceError to a Problem, picking the +// appropriate type from its HTTP status. The ResourceError is wrapped so its +// cause stays inspectable via errors.Is/As, but the client-facing detail is a +// fixed per-status string — the cause's text (which may include filesystem +// paths or upstream error specifics) is never serialized. +func FromResourceError(rerr *fetcher.ResourceError) *problem.Problem { + if rerr == nil { + return nil + } + switch rerr.HTTPStatus() { + case http.StatusNotFound: + return NotFound.Build().Wrap(rerr).Detail("Resource not found").Problem() + case http.StatusForbidden: + return Forbidden.Build().Wrap(rerr).Detail("Access to resource is forbidden").Problem() + case http.StatusRequestedRangeNotSatisfiable: + return RangeNotSatisfiable.Build().Wrap(rerr).Detail("Requested range is not satisfiable").Problem() + case http.StatusServiceUnavailable: + return ServiceUnavailable.Build().Wrap(rerr).Detail("Resource is temporarily unavailable").Problem() + case http.StatusBadGateway, http.StatusGatewayTimeout: + return BadGateway.Build().Wrap(rerr).Detail("Upstream resource fetch failed").Problem() + default: + return Internal("", rerr) + } +} diff --git a/pkg/serve/router.go b/pkg/serve/router.go index 9db455b..78dca38 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -1,16 +1,26 @@ package serve import ( + "log/slog" "net/http" "net/http/pprof" "github.com/CAFxX/httpcompression" + "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/readium/cli/pkg/serve/problems" ) func (s *Server) Routes() *mux.Router { r := mux.NewRouter() + r.Use(handlers.CORS( + handlers.AllowedOrigins(s.config.CORSAllowedOrigins), + handlers.AllowedMethods([]string{http.MethodGet, http.MethodHead, http.MethodOptions}), + handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Range"}), + handlers.ExposedHeaders([]string{"Content-Length", "Content-Range", "Accept-Ranges"}), + )) + r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) @@ -41,17 +51,30 @@ func (s *Server) Routes() *mux.Router { vars := mux.Vars(req) token := vars["path"] newRequest, aerr := s.config.Auth.Validate(w, req, token) - if aerr != nil { - if len(aerr.RedirectPath) > 0 { - ru, _ := r.Get("manifest").URLPath("path", aerr.RedirectPath) - http.Redirect(w, req, ru.String(), aerr.StatusCode) - return - } - - http.Error(w, aerr.Err.Error(), aerr.StatusCode) + if aerr == nil { + next.ServeHTTP(w, newRequest) + return + } + if len(aerr.RedirectPath) > 0 { + ru, _ := r.Get("manifest").URLPath("path", aerr.RedirectPath) + http.Redirect(w, req, ru.String(), aerr.StatusCode) return } - next.ServeHTTP(w, newRequest) + + slog.ErrorContext(req.Context(), "auth validation failed", "error", aerr.Err, "status", aerr.StatusCode) + + var p error + switch aerr.StatusCode { + case http.StatusBadRequest: + p = problems.BadRequest.Build().Wrap(aerr.Err).Detail(aerr.Err.Error()).Problem() + case http.StatusForbidden: + p = problems.Forbidden.Build().Wrap(aerr.Err).Detail(aerr.Err.Error()).Problem() + case http.StatusGone: + p = problems.Gone.Build().Wrap(aerr.Err).Detail(aerr.Err.Error()).Problem() + default: + p = problems.Internal("", aerr.Err) + } + problems.Write(p, w, req) }) }) pub.HandleFunc("", func(w http.ResponseWriter, req *http.Request) { diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 7fef09a..8b3faf5 100644 --- a/pkg/serve/server.go +++ b/pkg/serve/server.go @@ -48,6 +48,7 @@ type ServerConfig struct { InferA11yMetadata streamer.InferA11yMetadata Auth auth.AuthProvider ReadingSessionFetcher session.Fetcher + CORSAllowedOrigins []string } type Server struct { From f7a29a26d0ad3ed2ca6fd2067087bbc2add83113 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 02:38:10 -0700 Subject: [PATCH 06/13] make a few more errors internal --- pkg/serve/auth/bonding.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/serve/auth/bonding.go b/pkg/serve/auth/bonding.go index d3b1550..ba17f9f 100644 --- a/pkg/serve/auth/bonding.go +++ b/pkg/serve/auth/bonding.go @@ -178,25 +178,25 @@ func (b *bondingCore) validateBondingJWT(w http.ResponseWriter, r *http.Request, } subject, err := claims.GetSubject() if err != nil { - return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("failed extracting subject from JWT")} + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.New("failed extracting subject from JWT")} } claimDevice, err := base64.RawURLEncoding.DecodeString(claims.DeviceHash) if err != nil || len(claimDevice) != 32 { - return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid device hash in JWT")} + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.New("invalid device hash in JWT")} } claimAgent, err := base64.RawURLEncoding.DecodeString(claims.AgentHash) if err != nil || len(claimAgent) != 32 { - return nil, &AuthError{StatusCode: http.StatusBadRequest, Err: errors.New("invalid agent hash in JWT")} + return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.New("invalid agent hash in JWT")} } curDevice := b.deviceHash(deviceID) curAgent := b.agentHash(deviceID, r) if !bytes.Equal(curDevice[:], claimDevice) { - return nil, &AuthError{StatusCode: http.StatusForbidden, Err: errors.New("device hash mismatch")} + return nil, &AuthError{StatusCode: http.StatusForbidden, Err: errors.New("device integrity mismatch")} } if !bytes.Equal(curAgent[:], claimAgent) { - return nil, &AuthError{StatusCode: http.StatusForbidden, Err: errors.New("agent hash mismatch")} + return nil, &AuthError{StatusCode: http.StatusForbidden, Err: errors.New("browser integrity mismatch")} } b.setCookie(w, "device", deviceID.String(), time.Hour*24*90) From 219161f42d232596d28df53eadc7c5b6e0c8f7ff Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 02:57:32 -0700 Subject: [PATCH 07/13] change user-agent to be more "compatible" --- pkg/serve/client/http_auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/serve/client/http_auth.go b/pkg/serve/client/http_auth.go index 2ccf631..c8864fb 100644 --- a/pkg/serve/client/http_auth.go +++ b/pkg/serve/client/http_auth.go @@ -21,7 +21,7 @@ func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { } req2 := req.Clone(req.Context()) - req2.Header.Set("User-Agent", "readium/"+version.Version+" (go-toolkit "+gv.Version+")") + req2.Header.Set("User-Agent", "Mozilla/5.0 (compatible; readium/"+version.Version+"; go-toolkit/"+gv.Version+")") auth, ok := a.Authorization[req.URL.Host] if !ok { From 0b3d5ee2c2a11d813dacf02aa587e9402b470ed5 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 03:01:33 -0700 Subject: [PATCH 08/13] error-level logs only for internal auth failures --- pkg/serve/router.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/serve/router.go b/pkg/serve/router.go index 78dca38..10a96f6 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -61,17 +61,19 @@ func (s *Server) Routes() *mux.Router { return } - slog.ErrorContext(req.Context(), "auth validation failed", "error", aerr.Err, "status", aerr.StatusCode) - var p error switch aerr.StatusCode { case http.StatusBadRequest: + slog.DebugContext(req.Context(), "auth validation failed", "error", aerr.Err, "status", aerr.StatusCode) p = problems.BadRequest.Build().Wrap(aerr.Err).Detail(aerr.Err.Error()).Problem() case http.StatusForbidden: + slog.DebugContext(req.Context(), "auth validation failed", "error", aerr.Err, "status", aerr.StatusCode) p = problems.Forbidden.Build().Wrap(aerr.Err).Detail(aerr.Err.Error()).Problem() case http.StatusGone: + slog.DebugContext(req.Context(), "auth validation failed", "error", aerr.Err, "status", aerr.StatusCode) p = problems.Gone.Build().Wrap(aerr.Err).Detail(aerr.Err.Error()).Problem() default: + slog.ErrorContext(req.Context(), "auth validation failed", "error", aerr.Err, "status", aerr.StatusCode) p = problems.Internal("", aerr.Err) } problems.Write(p, w, req) From d07b41a985fae07642894e5696c64597289da830 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 03:03:38 -0700 Subject: [PATCH 09/13] update changelog --- CHANGELOG.MD | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 4475a7d..0d62d13 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -4,7 +4,23 @@ All notable changes to this project will be documented in this file. **Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution. -## [0.6.6] - 2025-03-09 +## [0.7.0] - 2026-05-18 + +### Changed + +- Updated dependencies +- HTTP requests made by `serve` now have a better, more descriptive `User-Agent` header + +### Added + +- A new `session` URL scheme has been added to `serve`, which allows for retrieval of a Reading Session API document. This is an experimental way of having a JSON object located at a remote URL (http, https) that returns the ebook's real location URL, as well as metadata to override, and rights (limitations) on the session. If you're trying to enforce restrictions seriously, you should use this together with one of the bonding modes below +- Two new modes have been added to `serve`, `jwt-bonding` and `jwks-bonding`. The difference between the two mirrots the `jwt` and `jwks` modes. Bonding (which must be combined with the Reading Session API) lets you more strongly tie a reading session (reader URL) to a web browser, through the use of a device cookie and a bonding JWT. Be sure to check out the new params available to tweak settings for sessions/bonding +- The webserver now strives to return [problem details in JSON](https://datatracker.ietf.org/doc/html/rfc9457), which should helps clients better understand what the cause of an error is +- A CORS setting (`--cors-allowed-origin`) is available for `serve`, for setups requiring CORS +- An HTTP authorization setting (`--http-host-authorization`) has been added, which will let you add the `authorization` header to specific hosts you're making requests to. This should help authenticate the reader for the Reading Session API endpoint (or whatever other needs you have like auth for your ebook storage domain) +- H2C (plaintext http2) support has been added to the webserver, for reverse proxies that support it + +## [0.6.6] - 2026-03-09 ### Fixed @@ -14,7 +30,7 @@ All notable changes to this project will be documented in this file. - Now, OPUS files has the mimetype `audio/opus` in manifests, but remain `audio/ogg; codecs=opus` in webserver responses for better compatibility -## [0.6.5] - 2025-02-28 +## [0.6.5] - 2026-02-28 ### Fixed From 42f6f003784609d2682aaab99d5a3bac2770bc3f Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 03:12:56 -0700 Subject: [PATCH 10/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/serve/auth/bonding.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/serve/auth/bonding.go b/pkg/serve/auth/bonding.go index ba17f9f..c831554 100644 --- a/pkg/serve/auth/bonding.go +++ b/pkg/serve/auth/bonding.go @@ -211,10 +211,9 @@ func (b *bondingCore) validateBondingJWT(w http.ResponseWriter, r *http.Request, Bonds: bonds, } - r, err = http.NewRequestWithContext(context.WithValue(context.WithValue(r.Context(), BondingRecordContextKey, bondData), ContextPathKey, subject), "GET", subject, nil) - if err != nil { - return nil, &AuthError{StatusCode: http.StatusInternalServerError, Err: errors.Wrap(err, "failed creating new request for bonded session")} - } + ctx := context.WithValue(r.Context(), BondingRecordContextKey, bondData) + ctx = context.WithValue(ctx, ContextPathKey, subject) + r = r.WithContext(ctx) return r, nil } From f4cfc02f57a040d0d3e123b62c28a0e62da26d37 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 03:24:14 -0700 Subject: [PATCH 11/13] allow credentials for CORS --- pkg/serve/router.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/serve/router.go b/pkg/serve/router.go index 10a96f6..58ecd4e 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -16,6 +16,7 @@ func (s *Server) Routes() *mux.Router { r.Use(handlers.CORS( handlers.AllowedOrigins(s.config.CORSAllowedOrigins), + handlers.AllowCredentials(), handlers.AllowedMethods([]string{http.MethodGet, http.MethodHead, http.MethodOptions}), handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Range"}), handlers.ExposedHeaders([]string{"Content-Length", "Content-Range", "Accept-Ranges"}), From b46fccc05b1ba6d7a9f0961ecb24905aebfd38c0 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 03:34:45 -0700 Subject: [PATCH 12/13] more copilot review issues --- pkg/serve/api.go | 216 ++++++++++++++++++++---------------- pkg/serve/cache/pubcache.go | 35 +++++- pkg/serve/session/api.go | 29 ++++- 3 files changed, 181 insertions(+), 99 deletions(-) diff --git a/pkg/serve/api.go b/pkg/serve/api.go index 4658143..90102b6 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -33,14 +33,14 @@ import ( "github.com/zeebo/xxh3" ) -func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, time.Time, error) { +func (s *Server) getPublication(ctx context.Context) (*cache.CachedPublication, error) { filename, ok := ctx.Value(auth.ContextPathKey).(string) if !ok { - return nil, false, time.Time{}, problems.Internal("missing publication path in context", nil) + return nil, problems.Internal("missing publication path in context", nil) } isSession := strings.HasPrefix(filename, session.SchemeReadingSession+":") - bap, bapok := s.config.Auth.(auth.BondingAuthProvider) + _, bapok := s.config.Auth.(auth.BondingAuthProvider) var u url.AbsoluteURL var cacheKey string if isSession { @@ -50,12 +50,12 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti } else { if bapok { // Cannot have non-session publication when bonding auth is enabled - return nil, false, time.Time{}, problems.BadRequest.Build(). + return nil, problems.BadRequest.Build(). Detail("non-session publication URLs are not allowed when bonding auth is enabled").Problem() } loc, err := url.URLFromString(filename) if err != nil { - return nil, false, time.Time{}, problems.BadRequest.Build().Wrap(err). + return nil, problems.BadRequest.Build().Wrap(err). Detail("failed creating URL from filepath").Problem() } u = url.BaseFile.Resolve(loc).(url.AbsoluteURL) // Turn relative filepaths into file:/// URLs @@ -67,38 +67,38 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti var doc *session.ReadingSessionDocument if isSession { if s.config.ReadingSessionFetcher == nil { - return nil, false, time.Time{}, problems.NotImplemented.Build(). + return nil, problems.NotImplemented.Build(). Detail("reading session API is not available").Problem() } cloc, err := nurl.Parse(filename) if err != nil { - return nil, false, time.Time{}, problems.BadRequest.Build().Wrap(err). + return nil, problems.BadRequest.Build().Wrap(err). Detail("failed parsing reading session URL").Problem() } // Example: session:https://example.com/data.json --> https://example.com/data.json if cloc.Opaque == "" { - return nil, false, time.Time{}, problems.BadRequest.Build(). + return nil, problems.BadRequest.Build(). Detail("reading session URL is missing data").Problem() } doc, err = s.config.ReadingSessionFetcher.Fetch(ctx, cloc.Opaque) if err != nil { - return nil, false, time.Time{}, problems.BadGateway.Build().Wrap(err). + return nil, problems.BadGateway.Build().Wrap(err). Detail("failed fetching reading session data").Problem() } if _, err := doc.Enforce(); err != nil { - return nil, false, time.Time{}, problems.From(err) + return nil, problems.From(err) } pubURL, hasPub := doc.PublicationURL() if !hasPub { - return nil, false, time.Time{}, problems.BadGateway.Build(). + return nil, problems.BadGateway.Build(). Detail("reading session document is missing a publication URL").Problem() } loc, err := url.URLFromString(pubURL) if err != nil { - return nil, false, time.Time{}, problems.Internal("failed creating URL from publication URL", err) + return nil, problems.Internal("failed creating URL from publication URL", err) } u = url.BaseFile.Resolve(loc).(url.AbsoluteURL) } @@ -115,18 +115,18 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti config.OnCreatePublication = doc.Injector() } if !s.remote.AcceptsScheme(u.Scheme()) { - return nil, remote, time.Time{}, problems.BadRequest.Build(). + return nil, problems.BadRequest.Build(). Detailf("unacceptable scheme %q", u.Scheme().String()).Problem() } if u.IsFile() { path, err := url.FromFilepath(filepath.Join(s.remote.LocalDirectory, path.Clean(u.Path()))) if err != nil { - return nil, remote, time.Time{}, problems.Internal("failed creating URL from filepath", err) + return nil, problems.Internal("failed creating URL from filepath", err) } pub, err = streamer.New(config).Open(ctx, asset.File(path), "") if err != nil { - return nil, remote, time.Time{}, problems.NotFound.Build().Wrap(err). + return nil, problems.NotFound.Build().Wrap(err). Detailf("failed opening %s", path.String()).Problem() } } else { @@ -134,41 +134,41 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti case url.SchemeS3: remote = true if s.remote.S3 == nil { - return nil, remote, time.Time{}, problems.NotImplemented.Build(). + return nil, problems.NotImplemented.Build(). Detail("S3 client not configured").Problem() } config.ArchiveFactory = archive.NewS3ArchiveFactory(s.remote.S3, archive.NewDefaultRemoteArchiveConfig()) pub, err = streamer.New(config).Open(ctx, asset.S3(s.remote.S3, u), "") if err != nil { - return nil, remote, time.Time{}, problems.BadGateway.Build().Wrap(err). + return nil, problems.BadGateway.Build().Wrap(err). Detailf("failed opening %s", u.String()).Problem() } case url.SchemeGS: remote = true if s.remote.GCS == nil { - return nil, remote, time.Time{}, problems.NotImplemented.Build(). + return nil, problems.NotImplemented.Build(). Detail("GCS client not configured").Problem() } config.ArchiveFactory = archive.NewGCSArchiveFactory(s.remote.GCS, archive.NewDefaultRemoteArchiveConfig()) pub, err = streamer.New(config).Open(ctx, asset.GCS(s.remote.GCS, u), "") if err != nil { - return nil, remote, time.Time{}, problems.BadGateway.Build().Wrap(err). + return nil, problems.BadGateway.Build().Wrap(err). Detailf("failed opening %s", u.String()).Problem() } case url.SchemeHTTP, url.SchemeHTTPS: remote = true if s.remote.HTTP == nil { - return nil, remote, time.Time{}, problems.NotImplemented.Build(). + return nil, problems.NotImplemented.Build(). Detail("HTTP client not configured").Problem() } config.ArchiveFactory = archive.NewHTTPArchiveFactory(s.remote.HTTP, archive.NewDefaultRemoteArchiveConfig()) pub, err = streamer.New(config).Open(ctx, asset.HTTP(s.remote.HTTP, u), "") if err != nil { - return nil, remote, time.Time{}, problems.BadGateway.Build().Wrap(err). + return nil, problems.BadGateway.Build().Wrap(err). Detailf("failed opening %s", u.String()).Problem() } default: - return nil, remote, time.Time{}, problems.BadRequest.Build(). + return nil, problems.BadRequest.Build(). Detailf("unsupported scheme %q", u.Scheme().String()).Problem() } } @@ -177,101 +177,124 @@ func (s *Server) getPublication(ctx context.Context) (*pub.Publication, bool, ti encPub := cache.EncapsulatePublication(pub, doc, remote) s.lfu.Set(cacheKey, encPub) - return encPub.Publication, remote, encPub.CachedAt, nil + // Record the bond for the opening device before returning, so a + // `devices: N` session cannot admit N+1 devices via subsequent cached + // requests that find an empty bond list. + if err := s.enforceBonding(ctx, doc); err != nil { + return nil, err + } + + return encPub, nil } cp := dat.(*cache.CachedPublication) - if cp.Session != nil && cp.Session.Rights != nil { - if bapok { - bd, ok := ctx.Value(auth.BondingRecordContextKey).(auth.BondingData) - if !ok { - return nil, false, time.Time{}, problems.Internal("missing bonding data in context for bonding auth provider", nil) - } - deviceCount := cp.Session.Rights.DeviceCount(bap.MaxDevices()) - if deviceCount > 0 { - // Ceiling for unreasonable per-subject device counts. - limit := deviceCount - if bap.MaxBondsPerSubject() > 0 && bap.MaxBondsPerSubject() < limit { - limit = bap.MaxBondsPerSubject() - } + cp.Mu.RLock() + sessionDoc := cp.Session + cp.Mu.RUnlock() - now := time.Now() - foundIdx := -1 - for i := range bd.Bonds { - if bd.Bonds[i].Device == bd.Device { - foundIdx = i - break - } - } - if foundIdx >= 0 { - bd.Bonds[foundIdx].Hash = bd.Hash - bd.Bonds[foundIdx].UpdatedAt = now - } else { - if uint16(len(bd.Bonds)) >= limit { - var newestBond time.Time - for _, b := range bd.Bonds { - if b.UpdatedAt.After(newestBond) { - newestBond = b.UpdatedAt - } - } - if time.Since(newestBond) < bap.MinDeviceEvictionInterval() { - return nil, false, time.Time{}, problems.DeviceLimitExceeded.Build(). - Detail("device limit exceeded for this publication").Problem() - } - bd.Evict(limit - 1) - } - bd.Bonds = append(bd.Bonds, auth.AgentBond{ - Device: bd.Device, - Hash: bd.Hash, - UpdatedAt: now, - }) - } - bap.Cache().Set(bd.Key, bd.Bonds) - } + if sessionDoc != nil && sessionDoc.Rights != nil { + if err := s.enforceBonding(ctx, sessionDoc); err != nil { + return nil, err } - refresh, err := cp.Session.Rights.Enforce() + refresh, err := sessionDoc.Rights.Enforce() if refresh { if s.config.ReadingSessionFetcher == nil { - return nil, false, time.Time{}, problems.NotImplemented.Build(). + return nil, problems.NotImplemented.Build(). Detail("reading session API is not available").Problem() } cloc, err := nurl.Parse(filename) if err != nil { - return nil, false, time.Time{}, problems.Internal("failed parsing reading session URL", err) + return nil, problems.Internal("failed parsing reading session URL", err) } // Example: session:https://example.com/data.json --> https://example.com/data.json if cloc.Opaque == "" { - return nil, false, time.Time{}, problems.Internal("reading session URL is missing data", nil) + return nil, problems.Internal("reading session URL is missing data", nil) } var doc *session.ReadingSessionDocument doc, err = s.config.ReadingSessionFetcher.Fetch(ctx, cloc.Opaque) if err != nil { - return nil, false, time.Time{}, problems.BadGateway.Build().Wrap(err). + return nil, problems.BadGateway.Build().Wrap(err). Detail("failed fetching reading session data").Problem() } - filename, _ = doc.PublicationURL() if _, err := doc.Enforce(); err != nil { - return nil, false, time.Time{}, problems.From(err) + return nil, problems.From(err) } - cp = cache.EncapsulatePublication(cp.Publication, doc, cp.Remote) + cp.RefreshSession(doc) s.lfu.Set(cacheKey, cp) } else if err != nil { - return nil, false, time.Time{}, problems.From(err) + return nil, problems.From(err) } } - return cp.Publication, cp.Remote, cp.CachedAt, nil + return cp, nil +} + +func (s *Server) enforceBonding(ctx context.Context, doc *session.ReadingSessionDocument) error { + if doc == nil || doc.Rights == nil { + return nil + } + bap, ok := s.config.Auth.(auth.BondingAuthProvider) + if !ok { + return nil + } + bd, ok := ctx.Value(auth.BondingRecordContextKey).(auth.BondingData) + if !ok { + return problems.Internal("missing bonding data in context for bonding auth provider", nil) + } + deviceCount := doc.Rights.DeviceCount(bap.MaxDevices()) + if deviceCount == 0 { + return nil + } + // Ceiling for unreasonable per-subject device counts. + limit := deviceCount + if bap.MaxBondsPerSubject() > 0 && bap.MaxBondsPerSubject() < limit { + limit = bap.MaxBondsPerSubject() + } + + now := time.Now() + foundIdx := -1 + for i := range bd.Bonds { + if bd.Bonds[i].Device == bd.Device { + foundIdx = i + break + } + } + if foundIdx >= 0 { + bd.Bonds[foundIdx].Hash = bd.Hash + bd.Bonds[foundIdx].UpdatedAt = now + } else { + if uint16(len(bd.Bonds)) >= limit { + var newestBond time.Time + for _, b := range bd.Bonds { + if b.UpdatedAt.After(newestBond) { + newestBond = b.UpdatedAt + } + } + if time.Since(newestBond) < bap.MinDeviceEvictionInterval() { + return problems.DeviceLimitExceeded.Build(). + Detail("device limit exceeded for this publication").Problem() + } + bd.Evict(limit - 1) + } + bd.Bonds = append(bd.Bonds, auth.AgentBond{ + Device: bd.Device, + Hash: bd.Hash, + UpdatedAt: now, + }) + } + bap.Cache().Set(bd.Key, bd.Bonds) + return nil } func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { vars := mux.Vars(req) // Load the publication - publication, _, cachedAt, err := s.getPublication(req.Context()) + cp, err := s.getPublication(req.Context()) if err != nil { slog.Error("failed opening publication", "error", err) problems.Write(err, w, req) @@ -286,7 +309,6 @@ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { scheme = "https://" } rPath, _ := s.router.Get("manifest").URLPath("path", vars["path"]) - conformsTo := conformsToAsMimetype(publication.Manifest.Metadata.ConformsTo) selfUrl, err := url.AbsoluteURLFromString(scheme + req.Host + rPath.String()) if err != nil { @@ -295,19 +317,24 @@ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { return } + // Hold the read lock while reading manifest fields and marshalling, so a + // concurrent RefreshSession cannot mutate the manifest mid-read. + cp.Mu.RLock() + conformsTo := conformsToAsMimetype(cp.Publication.Manifest.Metadata.ConformsTo) selfLink := &manifest.Link{ Rels: manifest.Strings{"self"}, MediaType: &conformsTo, Href: manifest.NewHREF(selfUrl), } - - // Marshal the manifest var j []byte if s.config.JSONIndent == "" { - j, err = json.Marshal(publication.Manifest.ToMap(selfLink)) + j, err = json.Marshal(cp.Publication.Manifest.ToMap(selfLink)) } else { - j, err = json.MarshalIndent(publication.Manifest.ToMap(selfLink), "", s.config.JSONIndent) + j, err = json.MarshalIndent(cp.Publication.Manifest.ToMap(selfLink), "", s.config.JSONIndent) } + cachedAt := cp.CachedAt + cp.Mu.RUnlock() + if err != nil { slog.Error("failed marshalling manifest JSON", "error", err) problems.Write(problems.Internal("failed marshalling manifest JSON", err), w, req) @@ -329,7 +356,7 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) // Load the publication - publication, remote, _, err := s.getPublication(r.Context()) + cp, err := s.getPublication(r.Context()) if err != nil { slog.Error("failed opening publication", "error", err) problems.Write(err, w, r) @@ -347,21 +374,22 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { rawHref.RawQuery = r.URL.Query().Encode() // Add the query parameters of the URL href, _ = url.RelativeURLFromGo(rawHref) // Turn it back into a go-toolkit relative URL - // Make sure the asset exists in the publication - link := publication.LinkWithHref(href) + // Resolve the link and acquire a resource handle under the read lock, + // so a concurrent RefreshSession cannot mutate the manifest mid-lookup. + cp.Mu.RLock() + link := cp.Publication.LinkWithHref(href) if link == nil { + cp.Mu.RUnlock() problems.Write(problems.NotFound.Build().Detailf("asset %q not found in publication", href.String()).Problem(), w, r) return } finalLink := *link - - // Expand templated links to include URL query parameters if finalLink.Href.IsTemplated() { finalLink.Href = manifest.NewHREF(finalLink.URL(nil, convertURLValuesToMap(r.URL.Query()))) } - - // Get the asset from the publication - res := publication.Get(r.Context(), finalLink) + res := cp.Publication.Get(r.Context(), finalLink) + cp.Mu.RUnlock() + remote := cp.Remote defer res.Close() // Get asset length in bytes @@ -373,7 +401,7 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { } // Patch mimetype where necessary - contentType := link.MediaType.String() + contentType := finalLink.MediaType.String() if sub, ok := mimeSubstitutions[contentType]; ok { contentType = sub } diff --git a/pkg/serve/cache/pubcache.go b/pkg/serve/cache/pubcache.go index c89d0a1..9fa95d4 100644 --- a/pkg/serve/cache/pubcache.go +++ b/pkg/serve/cache/pubcache.go @@ -1,6 +1,7 @@ package cache import ( + "sync" "time" "github.com/readium/cli/pkg/serve/session" @@ -13,10 +14,40 @@ type CachedPublication struct { Session *session.ReadingSessionDocument Remote bool CachedAt time.Time + + rightsService session.RightsService + Mu sync.RWMutex } -func EncapsulatePublication(pub *pub.Publication, session *session.ReadingSessionDocument, remote bool) *CachedPublication { - return &CachedPublication{pub, session, remote, time.Now()} +func EncapsulatePublication(p *pub.Publication, doc *session.ReadingSessionDocument, remote bool) *CachedPublication { + cp := &CachedPublication{ + Publication: p, + Session: doc, + Remote: remote, + CachedAt: time.Now(), + } + if doc != nil { + cp.rightsService = doc.RightsService() + } + return cp +} + +// RefreshSession swaps in a freshly-fetched reading session document and +// re-applies its metadata/links/rights onto the existing publication in place, +// avoiding an expensive reopen. The publication's injected rights service is +// updated atomically; the manifest mutation is guarded by Mu. +func (cp *CachedPublication) RefreshSession(doc *session.ReadingSessionDocument) { + cp.Mu.Lock() + defer cp.Mu.Unlock() + cp.Session = doc + cp.CachedAt = time.Now() + if doc == nil { + return + } + if cp.rightsService != nil && doc.Rights != nil { + cp.rightsService.Update(doc.Rights) + } + doc.Merge(&cp.Publication.Manifest) } func (cp *CachedPublication) OnEvict() { diff --git a/pkg/serve/session/api.go b/pkg/serve/session/api.go index b3b648c..dfa6b6b 100644 --- a/pkg/serve/session/api.go +++ b/pkg/serve/session/api.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "slices" + "sync/atomic" "time" "github.com/readium/go-toolkit/pkg/fetcher" @@ -142,6 +143,22 @@ type ReadingSessionDocument struct { Links manifest.LinkList `json:"links"` Rights *ReadingSessionRights `json:"rights,omitempty"` Metadata *ReadingSessionMetadata `json:"metadata,omitempty"` + + // rightsService is the publication service injected by Injector. It is + // retained so the rights it serves can be swapped after a TTL refresh + // without rebuilding the publication. + rightsService *readingSessionRightsService +} + +type RightsService interface { + Update(rights *ReadingSessionRights) +} + +func (d *ReadingSessionDocument) RightsService() RightsService { + if d.rightsService == nil { + return nil + } + return d.rightsService } // Merge overwrites fields in the manifest with any metadata provided @@ -292,8 +309,10 @@ func (d *ReadingSessionDocument) Injector() func(builder *pub.Builder) error { svc := &readingSessionRightsService{ link: link, - doc: d.Rights, } + svc.doc.Store(d.Rights) + d.rightsService = svc + factory := pub.ServiceFactory(func(_ pub.Context, _ bool) pub.Service { return svc }) @@ -312,7 +331,7 @@ func (d *ReadingSessionDocument) Enforce() (bool, error) { type readingSessionRightsService struct { link manifest.Link - doc *ReadingSessionRights + doc atomic.Pointer[ReadingSessionRights] } func (s *readingSessionRightsService) Links() manifest.LinkList { @@ -324,9 +343,13 @@ func (s *readingSessionRightsService) Get(_ context.Context, link manifest.Link) return nil, false } return fetcher.NewBytesResource(s.link, func() []byte { - data, _ := json.Marshal(s.doc) + data, _ := json.Marshal(s.doc.Load()) return data }), true } +func (s *readingSessionRightsService) Update(rights *ReadingSessionRights) { + s.doc.Store(rights) +} + func (s *readingSessionRightsService) Close() {} From 37df2ec4105b25f6ae4a855f43b2c7102d7144b9 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 18 May 2026 03:41:13 -0700 Subject: [PATCH 13/13] fix screwed up go.mod --- go.mod | 52 +++++----------------------------------------------- 1 file changed, 5 insertions(+), 47 deletions(-) diff --git a/go.mod b/go.mod index c2c2c0b..d8e974d 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,17 @@ module github.com/readium/cli -<<<<<<< content-api go 1.26 -======= -go 1.25.0 ->>>>>>> develop require ( cloud.google.com/go/storage v1.62.0 github.com/CAFxX/httpcompression v0.0.9 github.com/MicahParks/jwkset v0.11.0 -<<<<<<< content-api - github.com/MicahParks/keyfunc/v3 v3.7.0 - github.com/airmrcr/go-problem v0.5.0 - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.7 - github.com/aws/aws-sdk-go-v2/credentials v1.19.7 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 -======= github.com/MicahParks/keyfunc/v3 v3.8.0 + github.com/airmrcr/go-problem v0.5.0 github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/credentials v1.19.14 github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 ->>>>>>> develop github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -36,13 +24,9 @@ require ( github.com/spf13/cobra v1.10.2 github.com/vmihailenco/go-tinylfu v0.2.2 github.com/zeebo/xxh3 v1.1.0 -<<<<<<< content-api golang.org/x/sync v0.20.0 - google.golang.org/api v0.264.0 - lukechampine.com/blake3 v1.4.1 -======= google.golang.org/api v0.273.1 ->>>>>>> develop + lukechampine.com/blake3 v1.4.1 ) require ( @@ -81,12 +65,8 @@ require ( github.com/bbrks/go-blurhash v1.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711 // indirect -<<<<<<< content-api - github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect -======= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect ->>>>>>> develop + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set v1.8.0 // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect @@ -98,14 +78,8 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect -<<<<<<< content-api - github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect -======= - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.20.0 // indirect ->>>>>>> develop github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/pkcs7 v0.2.0 // indirect github.com/hhrutter/tiff v1.0.2 // indirect @@ -131,34 +105,18 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect -<<<<<<< content-api golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/image v0.35.0 // indirect + golang.org/x/image v0.38.0 // indirect golang.org/x/net v0.54.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect - golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect - google.golang.org/grpc v1.78.0 // indirect -======= - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/image v0.38.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401001100-f93e5f3e9f0f // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401001100-f93e5f3e9f0f // indirect google.golang.org/grpc v1.79.3 // indirect ->>>>>>> develop google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect