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 diff --git a/go.mod b/go.mod index f187345..d8e974d 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,32 @@ module github.com/readium/cli -go 1.25.0 +go 1.26 require ( cloud.google.com/go/storage v1.62.0 github.com/CAFxX/httpcompression v0.0.9 github.com/MicahParks/jwkset v0.11.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 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.20.0 google.golang.org/api v0.273.1 + lukechampine.com/blake3 v1.4.1 ) require ( @@ -34,6 +41,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.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 @@ -58,6 +66,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-20251210132809-ee656c7534f5 // 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.36.0 // indirect @@ -69,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.14 // indirect github.com/googleapis/gax-go/v2 v2.20.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect @@ -77,14 +85,15 @@ 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 + 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.39.0 // indirect @@ -96,14 +105,13 @@ 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 - golang.org/x/crypto v0.49.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.38.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.54.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/sys v0.44.0 // indirect + golang.org/x/text v0.37.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 @@ -111,4 +119,5 @@ require ( google.golang.org/grpc v1.79.3 // 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 19f5746..d3386c5 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,12 @@ github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8g github.com/MicahParks/keyfunc/v3 v3.8.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.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs= github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +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= @@ -231,6 +239,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= @@ -321,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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +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= @@ -381,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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +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= @@ -427,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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= @@ -450,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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -550,6 +560,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 95fd813..a81aa73 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/session" "github.com/readium/go-toolkit/pkg/streamer" "github.com/readium/go-toolkit/pkg/util/url" "github.com/spf13/cobra" @@ -47,6 +48,14 @@ var mode string var jwtSharedSecret string var jwksURL string +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 var s3RegionFlag string @@ -57,12 +66,15 @@ var s3UsePathStyleFlag bool var httpHostWhitelistFlag []string var httpUnsafeRequestsFlag bool var httpAuthorizationFlag string +var specificHttpAuthorizationFlag []string var remoteArchiveTimeoutFlag uint32 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", @@ -99,10 +111,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, session.SchemeReadingSession: 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, session", v) } } @@ -204,7 +216,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) } @@ -217,6 +241,12 @@ access to publications and prevent abuse or unauthorized access.`, remote.Config.Timeout = time.Duration(remoteArchiveTimeoutFlag) * time.Second remote.Config.CacheAllThreshold = int64(remoteArchiveCacheAll) + // Reading session fetcher + var readingSessionFetcher session.Fetcher + if slices.Contains(schemes, session.SchemeReadingSession) { + readingSessionFetcher = session.NewHTTPFetcher(remote.HTTP) + } + var authProvider auth.AuthProvider switch mode { case "base64": @@ -253,25 +283,104 @@ 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 !slices.Contains(schemes, session.SchemeReadingSession) { + return fmt.Errorf("in jwt-bonding mode, the reading session scheme 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 !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) + + 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, + Debug: debugFlag, + JSONIndent: indentFlag, + 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 { @@ -287,17 +396,25 @@ 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(&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") serveCmd.Flags().StringVar(&s3EndpointFlag, "s3-endpoint", "", "Custom S3 endpoint URL") @@ -309,9 +426,12 @@ 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)") 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 52dd51e..90102b6 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -10,13 +10,19 @@ 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/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" "github.com/readium/go-toolkit/pkg/fetcher" @@ -27,94 +33,271 @@ import ( "github.com/zeebo/xxh3" ) -func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publication, bool, time.Time, error) { - loc, err := url.URLFromString(filename) - if err != nil { - return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from filepath") +func (s *Server) getPublication(ctx context.Context) (*cache.CachedPublication, error) { + filename, ok := ctx.Value(auth.ContextPathKey).(string) + if !ok { + return nil, problems.Internal("missing publication path in context", nil) + } + + isSession := strings.HasPrefix(filename, session.SchemeReadingSession+":") + _, 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, 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, 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 - dat, ok := s.lfu.Get(u.String()) + dat, ok := s.lfu.Get(cacheKey) if !ok { + var doc *session.ReadingSessionDocument + if isSession { + if s.config.ReadingSessionFetcher == nil { + return nil, problems.NotImplemented.Build(). + Detail("reading session API is not available").Problem() + } + cloc, err := nurl.Parse(filename) + if err != nil { + 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, 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, problems.BadGateway.Build().Wrap(err). + Detail("failed fetching reading session data").Problem() + } + + if _, err := doc.Enforce(); err != nil { + return nil, problems.From(err) + } + + pubURL, hasPub := doc.PublicationURL() + if !hasPub { + 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, 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, 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()) + 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{}, errors.Wrap(err, "failed creating URL from filepath") + 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{}, errors.Wrap(err, "failed opening "+path.String()) + return nil, 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, 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, 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, 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, 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, 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, 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, problems.BadRequest.Build(). + Detailf("unsupported scheme %q", u.Scheme().String()).Problem() } } // Cache the publication - encPub := cache.EncapsulatePublication(pub, remote) - s.lfu.Set(u.String(), encPub) + encPub := cache.EncapsulatePublication(pub, doc, remote) + s.lfu.Set(cacheKey, encPub) + + // 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.Publication, remote, encPub.CachedAt, nil + return encPub, nil } cp := dat.(*cache.CachedPublication) - return cp.Publication, cp.Remote, cp.CachedAt, nil + + cp.Mu.RLock() + sessionDoc := cp.Session + cp.Mu.RUnlock() + + if sessionDoc != nil && sessionDoc.Rights != nil { + if err := s.enforceBonding(ctx, sessionDoc); err != nil { + return nil, err + } + + refresh, err := sessionDoc.Rights.Enforce() + if refresh { + if s.config.ReadingSessionFetcher == nil { + return nil, problems.NotImplemented.Build(). + Detail("reading session API is not available").Problem() + } + cloc, err := nurl.Parse(filename) + if err != nil { + 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, 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, problems.BadGateway.Build().Wrap(err). + Detail("failed fetching reading session data").Problem() + } + + if _, err := doc.Enforce(); err != nil { + return nil, problems.From(err) + } + + cp.RefreshSession(doc) + s.lfu.Set(cacheKey, cp) + } else if err != nil { + return nil, problems.From(err) + } + } + + 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) - filename := req.Context().Value(ContextPathKey).(string) // Load the publication - publication, _, cachedAt, err := s.getPublication(req.Context(), filename) + cp, 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 } @@ -126,44 +309,41 @@ 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 { 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 } + // 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) - 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) + `"` @@ -174,16 +354,12 @@ 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) + cp, 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 } @@ -191,43 +367,41 @@ 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() 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 { - w.WriteHeader(http.StatusNotFound) + 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 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 } // Patch mimetype where necessary - contentType := link.MediaType.String() + contentType := finalLink.MediaType.String() if sub, ok := mimeSubstitutions[contentType]; ok { contentType = sub } @@ -237,7 +411,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 @@ -246,12 +419,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/auth/auth.go b/pkg/serve/auth/auth.go index 01007f1..2699b8a 100644 --- a/pkg/serve/auth/auth.go +++ b/pkg/serve/auth/auth.go @@ -1,5 +1,15 @@ package auth +import ( + "net/http" +) + +type AuthError struct { + StatusCode int + Err error + RedirectPath string +} + type AuthProvider interface { - Validate(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..c831554 --- /dev/null +++ b/pkg/serve/auth/bonding.go @@ -0,0 +1,326 @@ +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.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.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.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 integrity mismatch")} + } + if !bytes.Equal(curAgent[:], claimAgent) { + return nil, &AuthError{StatusCode: http.StatusForbidden, Err: errors.New("browser integrity 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, + } + + ctx := context.WithValue(r.Context(), BondingRecordContextKey, bondData) + ctx = context.WithValue(ctx, ContextPathKey, subject) + r = r.WithContext(ctx) + 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 60fd06e..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(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 151b572..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(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 c98248e..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(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 1401a53..9fa95d4 100644 --- a/pkg/serve/cache/pubcache.go +++ b/pkg/serve/cache/pubcache.go @@ -1,20 +1,53 @@ package cache import ( + "sync" "time" + "github.com/readium/cli/pkg/serve/session" "github.com/readium/go-toolkit/pkg/pub" ) // CachedPublication implements Evictable type CachedPublication struct { *pub.Publication + Session *session.ReadingSessionDocument Remote bool CachedAt time.Time + + rightsService session.RightsService + Mu sync.RWMutex } -func EncapsulatePublication(pub *pub.Publication, remote bool) *CachedPublication { - return &CachedPublication{pub, 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/client/http_auth.go b/pkg/serve/client/http_auth.go index 8e3bd11..c8864fb 100644 --- a/pkg/serve/client/http_auth.go +++ b/pkg/serve/client/http_auth.go @@ -4,10 +4,13 @@ import ( "fmt" "net/http" "net/url" + + "github.com/readium/cli/internal/version" + gv "github.com/readium/go-toolkit/pkg/util/version" ) type authTransport struct { - Authorization string + Authorization map[string]string Whitelist []*url.URL Transport http.RoundTripper } @@ -16,12 +19,18 @@ 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()) + + req2.Header.Set("User-Agent", "Mozilla/5.0 (compatible; readium/"+version.Version+"; go-toolkit/"+gv.Version+")") - if a.Authorization == "" { - return a.transport().RoundTrip(req) + auth, ok := a.Authorization[req.URL.Host] + if !ok { + auth, ok = a.Authorization["*"] } - req2 := req.Clone(req.Context()) - req2.Header.Set("Authorization", a.Authorization) + if ok && len(auth) > 0 { + req2.Header.Set("Authorization", auth) + } + return a.transport().RoundTrip(req2) } @@ -32,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/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 b62791c..58ecd4e 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -1,21 +1,27 @@ package serve import ( - "context" + "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" ) -type ContextKey string - -const ContextPathKey ContextKey = "path" - func (s *Server) Routes() *mux.Router { r := mux.NewRouter() + 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"}), + )) + r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) @@ -42,15 +48,36 @@ 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(token) - if err != nil { - http.Error(w, err.Error(), status) + newRequest, aerr := s.config.Auth.Validate(w, req, token) + if aerr == nil { + next.ServeHTTP(w, newRequest) return } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ContextPathKey, newPath))) + if len(aerr.RedirectPath) > 0 { + ru, _ := r.Get("manifest").URLPath("path", aerr.RedirectPath) + http.Redirect(w, req, ru.String(), aerr.StatusCode) + return + } + + 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) }) }) pub.HandleFunc("", func(w http.ResponseWriter, req *http.Request) { diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 68593ac..8b3faf5 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/session" "github.com/readium/go-toolkit/pkg/archive" "github.com/readium/go-toolkit/pkg/streamer" "github.com/readium/go-toolkit/pkg/util/url" @@ -42,10 +43,12 @@ func (r Remote) AcceptsScheme(scheme url.Scheme) bool { } type ServerConfig struct { - Debug bool - JSONIndent string - InferA11yMetadata streamer.InferA11yMetadata - Auth auth.AuthProvider + Debug bool + JSONIndent string + InferA11yMetadata streamer.InferA11yMetadata + Auth auth.AuthProvider + ReadingSessionFetcher session.Fetcher + CORSAllowedOrigins []string } type Server struct { diff --git a/pkg/serve/session/api.go b/pkg/serve/session/api.go new file mode 100644 index 0000000..dfa6b6b --- /dev/null +++ b/pkg/serve/session/api.go @@ -0,0 +1,355 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "slices" + "sync/atomic" + "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 ReadingSessionStatus string + +const ( + 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 = 1 * time.Hour + +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 *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 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") + +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 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, ErrReadingSessionExpired + } + } + + 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 *ReadingSessionRights) UnmarshalJSON(data []byte) error { + type alias ReadingSessionRights + var obj alias + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + *r = ReadingSessionRights(obj) + r.refreshedAt = time.Now() + return nil +} + +// 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 ReadingSessionMetadata 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 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 +// 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 != "" { + 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 *ReadingSessionDocument) PublicationURL() (string, bool) { + for _, link := range d.Links { + if slices.Contains([]string(link.Rels), "publication") { + return link.Href.String(), true + } + } + return "", false +} + +const ReadingSessionDocumentService_Name pub.ServiceName = "ReadingSessionDocumentService" + +// 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) + + 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 := &readingSessionRightsService{ + link: link, + } + svc.doc.Store(d.Rights) + d.rightsService = svc + + factory := pub.ServiceFactory(func(_ pub.Context, _ bool) pub.Service { + return svc + }) + builder.ServicesBuilder.Set(ReadingSessionDocumentService_Name, &factory) + return nil + } +} + +func (d *ReadingSessionDocument) Enforce() (bool, error) { + if d.Rights == nil { + return false, nil + } + + return d.Rights.Enforce() +} + +type readingSessionRightsService struct { + link manifest.Link + doc atomic.Pointer[ReadingSessionRights] +} + +func (s *readingSessionRightsService) Links() manifest.LinkList { + return manifest.LinkList{s.link} +} + +func (s *readingSessionRightsService) 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.Load()) + return data + }), true +} + +func (s *readingSessionRightsService) Update(rights *ReadingSessionRights) { + s.doc.Store(rights) +} + +func (s *readingSessionRightsService) Close() {} diff --git a/pkg/serve/session/session.go b/pkg/serve/session/session.go new file mode 100644 index 0000000..4e3df05 --- /dev/null +++ b/pkg/serve/session/session.go @@ -0,0 +1,53 @@ +package session + +import ( + "context" + "fmt" + "net/http" + + "encoding/json" +) + +const SchemeReadingSession = "session" + +type Fetcher interface { + Fetch(ctx context.Context, url string) (*ReadingSessionDocument, error) +} + +type HTTPFetcher struct { + client *http.Client +} + +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 + } + 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("reading session API returned status %d", resp.StatusCode) + } + + var doc ReadingSessionDocument + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, fmt.Errorf("failed parsing reading session API response: %w", err) + } + + if _, ok := doc.PublicationURL(); !ok { + return nil, fmt.Errorf("reading session document has no publication link") + } + + return &doc, nil +} + +func NewHTTPFetcher(client *http.Client) Fetcher { + return &HTTPFetcher{ + client: client, + } +}