From ba0e37d5dad7461345eb2373b325de837d608d32 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 27 Oct 2025 00:52:29 -0700 Subject: [PATCH 01/16] reduce GOAMD64 to v2 (see #78) --- .goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index df87cfe..a66fb4c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -24,7 +24,7 @@ builds: goarm: - '7' goamd64: - - v3 + - v2 ldflags: - -s -w From 1a68d2fb4131f874f88d4c1b51b47eedf0967a9e Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 27 Oct 2025 00:59:37 -0700 Subject: [PATCH 02/16] require TLSv1.2 or greater in the HTTP client --- pkg/serve/client/http_client.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pkg/serve/client/http_client.go b/pkg/serve/client/http_client.go index 6d9f84f..9b4bdd1 100644 --- a/pkg/serve/client/http_client.go +++ b/pkg/serve/client/http_client.go @@ -1,6 +1,7 @@ package client import ( + "crypto/tls" "errors" "fmt" "net" @@ -56,13 +57,16 @@ func NewHTTPClient(auth string, whitelist []*url.URL, bypassSafeSocketControl bo } safeTransport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: safeDialer.DialContext, - ForceAttemptHTTP2: true, - MaxIdleConns: 100, - MaxIdleConnsPerHost: Workers + 1, - IdleConnTimeout: time.Duration(ClientKeepAliveTimeout) * time.Second, - TLSHandshakeTimeout: 10 * time.Second, + Proxy: http.ProxyFromEnvironment, + DialContext: safeDialer.DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + MaxIdleConnsPerHost: Workers + 1, + IdleConnTimeout: time.Duration(ClientKeepAliveTimeout) * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, ExpectContinueTimeout: 1 * time.Second, } From d1802d92a10c7038278fbdadd5e90c4e6936ba2e Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 27 Oct 2025 02:33:58 -0700 Subject: [PATCH 03/16] implement authentication for serve command --- go.mod | 3 ++ go.sum | 8 +++-- internal/cli/serve.go | 53 +++++++++++++++++++++++++++++++ pkg/serve/api.go | 22 ++++++++----- pkg/serve/auth/auth.go | 5 +++ pkg/serve/auth/encoded.go | 20 ++++++++++++ pkg/serve/auth/jwks.go | 65 +++++++++++++++++++++++++++++++++++++++ pkg/serve/auth/jwt.go | 57 ++++++++++++++++++++++++++++++++++ pkg/serve/router.go | 22 +++++++++++-- pkg/serve/server.go | 5 +++ 10 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 pkg/serve/auth/auth.go create mode 100644 pkg/serve/auth/encoded.go create mode 100644 pkg/serve/auth/jwks.go create mode 100644 pkg/serve/auth/jwt.go diff --git a/go.mod b/go.mod index 8aabae7..43c62a2 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,13 @@ go 1.24.2 require ( cloud.google.com/go/storage v1.57.0 github.com/CAFxX/httpcompression v0.0.9 + github.com/MicahParks/jwkset v0.11.0 + github.com/MicahParks/keyfunc/v3 v3.7.0 github.com/aws/aws-sdk-go-v2 v1.39.2 github.com/aws/aws-sdk-go-v2/config v1.31.12 github.com/aws/aws-sdk-go-v2/credentials v1.18.16 github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gorilla/mux v1.8.1 github.com/gotd/contrib v0.21.1 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index ae7007a..d91c920 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= +github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/agext/regexp v1.3.0 h1:6+9tp+S41TU48gFNV47bX+pp1q7WahGofw6JccmsCDs= github.com/agext/regexp v1.3.0/go.mod h1:6phv1gViOJXWcTfpxOi9VMS+MaSAo+SUDf7do3ur1HA= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= @@ -143,6 +147,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -235,8 +241,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/readium/go-toolkit v0.12.0 h1:+aTSPt6LZkjWtsU0Al+kMLi0/3kCjEzPTmGW1Sl7jnI= -github.com/readium/go-toolkit v0.12.0/go.mod h1:JbWaW+zzXFTWIde55PH0s6HQKnJguqh0ZSh9C/fzQgU= github.com/readium/go-toolkit v0.12.1 h1:pkCbRu4uVRsojUFvJ/gQ9XN5NGUEFSAN+OkONHEqAh4= github.com/readium/go-toolkit v0.12.1/go.mod h1:JbWaW+zzXFTWIde55PH0s6HQKnJguqh0ZSh9C/fzQgU= github.com/readium/xmlquery v0.0.0-20230106230237-8f493145aef4 h1:iEQhT4jOppg7EK/r4/1e4ULIeCsugv35O+sDlvce5Bo= diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 87fb18c..c3ffd2d 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -2,6 +2,8 @@ package cli import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "log" "net/http" @@ -22,6 +24,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/pkg/errors" "github.com/readium/cli/pkg/serve" + "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/client" "github.com/readium/go-toolkit/pkg/streamer" "github.com/readium/go-toolkit/pkg/util/url" @@ -39,6 +42,11 @@ var schemeFlag []string var fileDirectoryFlag string +var mode string + +var jwtSharedSecret string +var jwksURL string + // Cloud-related flags var s3EndpointFlag string var s3RegionFlag string @@ -213,11 +221,52 @@ implement any authentication, and may have more access to files than expected.`, remote.Config.Timeout = time.Duration(remoteArchiveTimeoutFlag) * time.Second remote.Config.CacheAllThreshold = int64(remoteArchiveCacheAll) + var authProvider auth.AuthProvider + switch mode { + case "open": + authProvider = auth.NewEncodedAuthProvider() + slog.Info("Operating in open access mode (insecure)") + case "jwt": + 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", "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", "secret", "") + } + authProvider, err = auth.NewJWTAuthProvider(sharedSecret) + if err != nil { + return fmt.Errorf("failed creating JWT auth provider: %w", err) + } + case "jwks": + if jwksURL == "" { + return fmt.Errorf("jwks-url must be specified in jwks mode") + } + slog.Info("Operating in JWKS JWT access mode", "jwks_url", jwksURL) + authProvider, err = auth.NewJWKSAuthProvider(context.Background(), remote.HTTP, jwksURL) + if err != nil { + return fmt.Errorf("failed creating JWKS auth provider: %w", err) + } + default: + return fmt.Errorf("invalid access mode %q, acceptable values: open, jwt, jwks", mode) + } + // Create server pubServer := serve.NewServer(serve.ServerConfig{ Debug: debugFlag, JSONIndent: indentFlag, InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag), + Auth: authProvider, }, remote) bind := fmt.Sprintf("%s:%d", bindAddressFlag, bindPortFlag) @@ -248,6 +297,10 @@ func init() { 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", "open", "Access mode: open (simple base64 URLs), jwt (JWT auth with a shared secret), jwks (JWT auth with keys in a JWKS)") + + 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().StringVar(&fileDirectoryFlag, "file-directory", "", "Local directory path to serve publications from") diff --git a/pkg/serve/api.go b/pkg/serve/api.go index dd9dc84..3659665 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -61,11 +61,7 @@ func (s *Server) demoList(w http.ResponseWriter, req *http.Request) { } func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publication, bool, time.Time, error) { - fpath, err := base64.RawURLEncoding.DecodeString(filename) - if err != nil { - return nil, false, time.Time{}, err - } - loc, err := url.URLFromString(string(fpath)) + loc, err := url.URLFromString(filename) if err != nil { return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from filepath") } @@ -142,7 +138,7 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) { vars := mux.Vars(req) - filename := vars["path"] + filename := req.Context().Value(ContextPathKey).(string) // Load the publication publication, _, cachedAt, err := s.getPublication(req.Context(), filename) @@ -211,7 +207,7 @@ 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 := vars["path"] + filename := r.Context().Value(ContextPathKey).(string) // Load the publication publication, remote, _, err := s.getPublication(r.Context(), filename) @@ -306,6 +302,10 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { cres, ok := res.(fetcher.CompressedResource) normalResponse := func() { + if r.Method == http.MethodHead { + return + } + if remote { var bin []byte bin, rerr = res.Read(r.Context(), start, end) @@ -326,6 +326,10 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-encoding", "deflate") w.Header().Set("content-length", strconv.FormatInt(cres.CompressedLength(r.Context()), 10)) } + if r.Method == http.MethodHead { + headers() + return + } if remote { var bin []byte bin, rerr = cres.ReadCompressed(r.Context()) @@ -345,6 +349,10 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-encoding", "gzip") w.Header().Set("content-length", strconv.FormatInt(cres.CompressedLength(r.Context())+archive.GzipWrapperLength, 10)) } + if r.Method == http.MethodHead { + headers() + return + } if remote { var bin []byte bin, rerr = cres.ReadCompressedGzip(r.Context()) diff --git a/pkg/serve/auth/auth.go b/pkg/serve/auth/auth.go new file mode 100644 index 0000000..01007f1 --- /dev/null +++ b/pkg/serve/auth/auth.go @@ -0,0 +1,5 @@ +package auth + +type AuthProvider interface { + Validate(token string) (string, int, error) +} diff --git a/pkg/serve/auth/encoded.go b/pkg/serve/auth/encoded.go new file mode 100644 index 0000000..b81a267 --- /dev/null +++ b/pkg/serve/auth/encoded.go @@ -0,0 +1,20 @@ +package auth + +import ( + "encoding/base64" + "fmt" +) + +type EncodedAuthProvider struct{} + +func (n *EncodedAuthProvider) Validate(token string) (string, int, error) { + path, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return "", 400, fmt.Errorf("invalid base64url path: %w", err) + } + return string(path), 200, nil +} + +func NewEncodedAuthProvider() *EncodedAuthProvider { + return &EncodedAuthProvider{} +} diff --git a/pkg/serve/auth/jwks.go b/pkg/serve/auth/jwks.go new file mode 100644 index 0000000..151b572 --- /dev/null +++ b/pkg/serve/auth/jwks.go @@ -0,0 +1,65 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/MicahParks/jwkset" + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" +) + +type JWKSAuthProvider struct { + kf keyfunc.Keyfunc + parser *jwt.Parser +} + +func (j *JWKSAuthProvider) Validate(token string) (string, int, error) { + t, err := j.parser.Parse(token, j.kf.Keyfunc) + if err != nil { + if errors.Is(err, jwkset.ErrKeyNotFound) { + return "", http.StatusBadRequest, err + } else if errors.Is(err, jwt.ErrTokenMalformed) { + return "", http.StatusBadRequest, err + } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) { + return "", http.StatusBadRequest, err + } else if errors.Is(err, jwt.ErrTokenExpired) { + return "", http.StatusGone, err + } else { + return "", http.StatusInternalServerError, err + } + } + if !t.Valid { + return "", http.StatusBadRequest, errors.New("invalid JWT token") + } + subject, err := t.Claims.GetSubject() + if err != nil { + return "", http.StatusBadRequest, errors.New("failed extracting subject from JWT") + } + if subject == "" { + return "", http.StatusBadRequest, errors.New("JWT subject is empty") + } + + return subject, http.StatusOK, nil +} + +func NewJWKSAuthProvider(context context.Context, client *http.Client, jwksUrl string) (*JWKSAuthProvider, error) { + if len(jwksUrl) == 0 { + return nil, errors.New("JWKS URL is empty") + } + + kf, err := keyfunc.NewDefaultOverrideCtx(context, []string{jwksUrl}, keyfunc.Override{ + Client: client, + RefreshInterval: time.Hour * 12, + }) + if err != nil { + return nil, err + } + + return &JWKSAuthProvider{ + kf: kf, + parser: jwt.NewParser(), + }, nil +} diff --git a/pkg/serve/auth/jwt.go b/pkg/serve/auth/jwt.go new file mode 100644 index 0000000..51544b6 --- /dev/null +++ b/pkg/serve/auth/jwt.go @@ -0,0 +1,57 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" +) + +type JWTAuthProvider struct { + sharedSecret []byte + parser *jwt.Parser +} + +func (j *JWTAuthProvider) Validate(token string) (string, int, error) { + t, err := j.parser.Parse(token, func(t *jwt.Token) (interface{}, 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 + } else if errors.Is(err, jwt.ErrTokenMalformed) { + return "", http.StatusBadRequest, err + } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) { + return "", http.StatusBadRequest, err + } else if errors.Is(err, jwt.ErrTokenExpired) { + return "", http.StatusGone, err + } else { + return "", http.StatusInternalServerError, err + } + } + if !t.Valid { + return "", http.StatusBadRequest, errors.New("invalid JWT token") + } + subject, err := t.Claims.GetSubject() + if err != nil { + return "", http.StatusBadRequest, errors.New("failed extracting subject from JWT") + } + if subject == "" { + return "", http.StatusBadRequest, errors.New("JWT subject is empty") + } + + return subject, http.StatusOK, nil +} + +func NewJWTAuthProvider(sharedSecret []byte) (*JWTAuthProvider, error) { + if len(sharedSecret) < 8 { + return nil, errors.New("length of JWT shared secret is less than 8 bytes") + } + + return &JWTAuthProvider{ + sharedSecret: sharedSecret, + parser: jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})), + }, nil +} diff --git a/pkg/serve/router.go b/pkg/serve/router.go index e3b5fd1..ecb578d 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -1,6 +1,7 @@ package serve import ( + "context" "net/http" "net/http/pprof" @@ -8,6 +9,10 @@ import ( "github.com/gorilla/mux" ) +type ContextKey string + +const ContextPathKey ContextKey = "path" + func (s *Server) Routes() *mux.Router { r := mux.NewRouter() @@ -34,10 +39,21 @@ func (s *Server) Routes() *mux.Router { r.HandleFunc("/list.json", s.demoList).Name("demo_list") pub := r.PathPrefix("/{path}").Subrouter() - // TODO: publication loading middleware with pub.Use() - pub.Use(func(h http.Handler) http.Handler { + pub.Use(func(next http.Handler) http.Handler { adapter, _ := httpcompression.DefaultAdapter(httpcompression.ContentTypes(compressableMimes, false)) - return adapter(h) + 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) + token := vars["path"] + newPath, status, err := s.config.Auth.Validate(token) + if err != nil { + http.Error(w, err.Error(), status) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ContextPathKey, newPath))) + }) }) pub.HandleFunc("/manifest.json", s.getManifest).Name("manifest") pub.HandleFunc("/{asset:.*}", s.getAsset).Name("asset") diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 35a8182..10939f8 100644 --- a/pkg/serve/server.go +++ b/pkg/serve/server.go @@ -7,6 +7,7 @@ import ( "cloud.google.com/go/storage" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gorilla/mux" + "github.com/readium/cli/pkg/serve/auth" "github.com/readium/cli/pkg/serve/cache" "github.com/readium/go-toolkit/pkg/archive" "github.com/readium/go-toolkit/pkg/streamer" @@ -44,6 +45,7 @@ type ServerConfig struct { Debug bool JSONIndent string InferA11yMetadata streamer.InferA11yMetadata + Auth auth.AuthProvider } type Server struct { @@ -57,6 +59,9 @@ const MaxCachedPublicationAmount = 10 const MaxCachedPublicationTTL = time.Second * time.Duration(600) func NewServer(config ServerConfig, remote Remote) *Server { + if config.Auth == nil { + config.Auth = auth.NewEncodedAuthProvider() + } return &Server{ config: config, remote: remote, From 6b3eb36875adadfbbbb0dddde2616f0ad8ee6285 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 27 Oct 2025 02:35:29 -0700 Subject: [PATCH 04/16] remove demo list endpoint --- pkg/serve/api.go | 33 --------------------------------- pkg/serve/router.go | 2 -- 2 files changed, 35 deletions(-) diff --git a/pkg/serve/api.go b/pkg/serve/api.go index 3659665..52dd51e 100644 --- a/pkg/serve/api.go +++ b/pkg/serve/api.go @@ -3,11 +3,9 @@ package serve import ( "bytes" "context" - "encoding/base64" "encoding/json" "log/slog" "net/http" - "os" "path" "path/filepath" "slices" @@ -29,37 +27,6 @@ import ( "github.com/zeebo/xxh3" ) -type demoListItem struct { - Filename string `json:"filename"` - Path string `json:"path"` -} - -// TODO: replace with OPDS or something better -func (s *Server) demoList(w http.ResponseWriter, req *http.Request) { - if s.remote.LocalDirectory == "" { - slog.Warn("demo publication list requested, but no local directory configured") - w.WriteHeader(404) - return - } - - fi, err := os.ReadDir(s.remote.LocalDirectory) - if err != nil { - slog.Error("failed reading publications directory", "error", err) - w.WriteHeader(500) - return - } - files := make([]demoListItem, len(fi)) - for i, f := range fi { - files[i] = demoListItem{ - Filename: f.Name(), - Path: base64.RawURLEncoding.EncodeToString([]byte(f.Name())), - } - } - enc := json.NewEncoder(w) - enc.SetIndent("", s.config.JSONIndent) - enc.Encode(files) -} - func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publication, bool, time.Time, error) { loc, err := url.URLFromString(filename) if err != nil { diff --git a/pkg/serve/router.go b/pkg/serve/router.go index ecb578d..c84b80f 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -36,8 +36,6 @@ func (s *Server) Routes() *mux.Router { r.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) } - r.HandleFunc("/list.json", s.demoList).Name("demo_list") - pub := r.PathPrefix("/{path}").Subrouter() pub.Use(func(next http.Handler) http.Handler { adapter, _ := httpcompression.DefaultAdapter(httpcompression.ContentTypes(compressableMimes, false)) From 73ba3ad0e6451210220a04b1965397bd94f59dca Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 27 Oct 2025 02:42:18 -0700 Subject: [PATCH 05/16] Update serve command description --- internal/cli/serve.go | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index c3ffd2d..0864d91 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -65,10 +65,10 @@ var remoteArchiveCacheAll uint32 var serveCmd = &cobra.Command{ Use: "serve", - Short: "Start a local HTTP server, serving a specified directory of publications", - Long: `Start a local HTTP server, serving a specified directory of publications. + Short: "Start a local HTTP server, serving publications locally or remotely", + Long: `Start a local HTTP server, serving publications locally or remotely. -This command will start an HTTP serve listening by default on 'localhost:15080', +This command will start an HTTP server listening by default on 'localhost:15080', serving all compatible files (EPUB, PDF, CBZ, etc.) available from the enabled access schemes (file, http, https, s3, gs, or a local path if file scheme is enabled) as Readium Web Publications. To get started, the manifest can be accessed from @@ -76,13 +76,9 @@ as Readium Web Publications. To get started, the manifest can be accessed from This file serves as the entry point and contains metadata and links to the rest of the files that can be accessed for the publication. -If local file access is enabled, the server also exposes a '/list.json' endpoint that, -for debugging purposes, returns a list of all the publications found in the directory -along with their encoded paths. This will be replaced by an OPDS 2 feed (or similar) -in a future release. - -Note: Take caution before exposing this server on the internet. It does not -implement any authentication, and may have more access to files than expected.`, +Authentication can be enabled using the -m flag, which replaces the encoded path +with a JWT. Before exposing this server publicly, consider using this flag to secure +access to publications and prevent abuse or unauthorized access.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { // For users migrating from previous versions of the CLI From ee2f89ca7bb4fd659b3e98d1232047c8f39cbdf4 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 27 Oct 2025 02:51:25 -0700 Subject: [PATCH 06/16] update changelog --- CHANGELOG.MD | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 932c3b2..198b585 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -4,6 +4,22 @@ 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.0] - 2025-10-27 + +### Added + +- When using the serve command, a new `-m` flag allows for authenticated access to publications using a JWT in the route to the publication instead of the encoded path. The subject (`sub`) of the JWT will instead be used as the path to the publication. The first new mode is `jwt` mode, which uses the HS256 method of authentication and a shared secret that is either provided using `--jwt-shared-secret` or autogenerated at startup. The second mode, `jwks`, is combined with the `--jwks-url` flag that points to JWKS file, which can contain multiple keys used to validate the JWT, allowing for key rotation and other algorithms using public/private keypairs + +### Changed + +- The GOAMD64 value for release builds has been changed from `v3` to `v2`. The discussion regarding this is [here](https://github.com/readium/cli/issues/78). This allows execution of the built binaries on older x64 CPUs +- The HTTP client configuration used for streaming of remote publications has been changed to require, at minimum, TLSv1.2 for HTTPS connections + +### Removed + +- The `/list.json` route in the serve command's webserver has been removed. It is not compatible with the new authenticated access schemes, and was only intended to be temporary. It may be replaced in the future by an OPDS2 feed + + ## [0.5.1] - 2025-10-14 ### Fixed From b7d342851f419ac4c3d20a8faa3e6c402e76c46b Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 2 Nov 2025 13:37:06 -0800 Subject: [PATCH 07/16] increase WriteTimeout for serve command's webserver --- internal/cli/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 0864d91..c494775 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -268,7 +268,7 @@ access to publications and prevent abuse or unauthorized access.`, bind := fmt.Sprintf("%s:%d", bindAddressFlag, bindPortFlag) httpServer := &http.Server{ ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, + WriteTimeout: 600 * time.Second, // 5 minutes for server to respond with resource MaxHeaderBytes: 1 << 20, Addr: bind, Handler: pubServer.Routes(), From 49f352697c39ebd47f1e22b1b3f387a4bcea1fd0 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 2 Nov 2025 14:23:36 -0800 Subject: [PATCH 08/16] add `/webpub` prefix to publication server, redirect from path to manifest --- pkg/serve/router.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/serve/router.go b/pkg/serve/router.go index c84b80f..b62791c 100644 --- a/pkg/serve/router.go +++ b/pkg/serve/router.go @@ -36,7 +36,7 @@ func (s *Server) Routes() *mux.Router { r.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) } - pub := r.PathPrefix("/{path}").Subrouter() + pub := r.PathPrefix("/webpub/{path}").Subrouter() pub.Use(func(next http.Handler) http.Handler { adapter, _ := httpcompression.DefaultAdapter(httpcompression.ContentTypes(compressableMimes, false)) return adapter(next) @@ -53,6 +53,10 @@ func (s *Server) Routes() *mux.Router { next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ContextPathKey, newPath))) }) }) + pub.HandleFunc("", func(w http.ResponseWriter, req *http.Request) { + ru, _ := r.Get("manifest").URLPath("path", mux.Vars(req)["path"]) + http.Redirect(w, req, ru.String(), http.StatusFound) + }) pub.HandleFunc("/manifest.json", s.getManifest).Name("manifest") pub.HandleFunc("/{asset:.*}", s.getAsset).Name("asset") From 9cad76f11268b7f2b36abaf5c387b612a508301a Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 2 Nov 2025 14:26:25 -0800 Subject: [PATCH 09/16] update changelog --- CHANGELOG.MD | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 198b585..516167e 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -9,17 +9,18 @@ All notable changes to this project will be documented in this file. ### Added - When using the serve command, a new `-m` flag allows for authenticated access to publications using a JWT in the route to the publication instead of the encoded path. The subject (`sub`) of the JWT will instead be used as the path to the publication. The first new mode is `jwt` mode, which uses the HS256 method of authentication and a shared secret that is either provided using `--jwt-shared-secret` or autogenerated at startup. The second mode, `jwks`, is combined with the `--jwks-url` flag that points to JWKS file, which can contain multiple keys used to validate the JWT, allowing for key rotation and other algorithms using public/private keypairs +- The path of a publication with no resource specified now redirects to the manifest file ### Changed - The GOAMD64 value for release builds has been changed from `v3` to `v2`. The discussion regarding this is [here](https://github.com/readium/cli/issues/78). This allows execution of the built binaries on older x64 CPUs - The HTTP client configuration used for streaming of remote publications has been changed to require, at minimum, TLSv1.2 for HTTPS connections +- The serve command's routes are now prefixed with `/webpub`. So `//manifest.json` is now `/webpub//manifest.json` ### Removed - The `/list.json` route in the serve command's webserver has been removed. It is not compatible with the new authenticated access schemes, and was only intended to be temporary. It may be replaced in the future by an OPDS2 feed - ## [0.5.1] - 2025-10-14 ### Fixed From 815fe2231d507cc4aaddf942e3d06ea74454b3f0 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 2 Nov 2025 14:27:22 -0800 Subject: [PATCH 10/16] move version logic to its own internal package --- internal/cli/root.go | 5 +++-- internal/{cli => version}/version.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) rename internal/{cli => version}/version.go (98%) diff --git a/internal/cli/root.go b/internal/cli/root.go index 567f9fd..be675ab 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,7 +3,8 @@ package cli import ( "os" - "github.com/readium/go-toolkit/pkg/util/version" + "github.com/readium/cli/internal/version" + gv "github.com/readium/go-toolkit/pkg/util/version" "github.com/spf13/cobra" ) @@ -17,7 +18,7 @@ var rootCmd = &cobra.Command{ // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if rootCmd.Version == "" { - rootCmd.Version = Version + " (go-toolkit " + version.Version + ")" + rootCmd.Version = version.Version + " (go-toolkit " + gv.Version + ")" } err := rootCmd.Execute() if err != nil { diff --git a/internal/cli/version.go b/internal/version/version.go similarity index 98% rename from internal/cli/version.go rename to internal/version/version.go index baa01c0..e7d982a 100644 --- a/internal/cli/version.go +++ b/internal/version/version.go @@ -1,4 +1,4 @@ -package cli +package version import ( "runtime/debug" From a2e067e575df8f020f706a1298bad48839c8d9a2 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 3 Nov 2025 13:11:23 -0800 Subject: [PATCH 11/16] adjustments --- internal/cli/serve.go | 8 ++++---- pkg/serve/auth/encoded.go | 8 ++++---- pkg/serve/server.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index c494775..5f80873 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -219,9 +219,9 @@ access to publications and prevent abuse or unauthorized access.`, var authProvider auth.AuthProvider switch mode { - case "open": - authProvider = auth.NewEncodedAuthProvider() - slog.Info("Operating in open access mode (insecure)") + case "base64": + authProvider = auth.NewB64EncodedAuthProvider() + slog.Info("Operating in open access mode with base64url encoding (insecure)") case "jwt": var sharedSecret []byte if jwtSharedSecret == "" { @@ -293,7 +293,7 @@ func init() { 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", "open", "Access mode: open (simple base64 URLs), 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)") 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") diff --git a/pkg/serve/auth/encoded.go b/pkg/serve/auth/encoded.go index b81a267..a4fec2b 100644 --- a/pkg/serve/auth/encoded.go +++ b/pkg/serve/auth/encoded.go @@ -5,9 +5,9 @@ import ( "fmt" ) -type EncodedAuthProvider struct{} +type B64EncodedAuthProvider struct{} -func (n *EncodedAuthProvider) Validate(token string) (string, int, error) { +func (n *B64EncodedAuthProvider) Validate(token string) (string, int, error) { path, err := base64.RawURLEncoding.DecodeString(token) if err != nil { return "", 400, fmt.Errorf("invalid base64url path: %w", err) @@ -15,6 +15,6 @@ func (n *EncodedAuthProvider) Validate(token string) (string, int, error) { return string(path), 200, nil } -func NewEncodedAuthProvider() *EncodedAuthProvider { - return &EncodedAuthProvider{} +func NewB64EncodedAuthProvider() *B64EncodedAuthProvider { + return &B64EncodedAuthProvider{} } diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 10939f8..68593ac 100644 --- a/pkg/serve/server.go +++ b/pkg/serve/server.go @@ -60,7 +60,7 @@ const MaxCachedPublicationTTL = time.Second * time.Duration(600) func NewServer(config ServerConfig, remote Remote) *Server { if config.Auth == nil { - config.Auth = auth.NewEncodedAuthProvider() + config.Auth = auth.NewB64EncodedAuthProvider() } return &Server{ config: config, From 43d5267e74342dac36da8c145f038a80f4aaf41d Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 3 Nov 2025 13:11:38 -0800 Subject: [PATCH 12/16] switch to switch --- pkg/serve/helpers.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/serve/helpers.go b/pkg/serve/helpers.go index 01f50bc..e1321b1 100644 --- a/pkg/serve/helpers.go +++ b/pkg/serve/helpers.go @@ -58,11 +58,12 @@ var compressableMimes = []string{ func conformsToAsMimetype(conformsTo manifest.Profiles) mediatype.MediaType { mime := mediatype.ReadiumWebpubManifest for _, profile := range conformsTo { - if profile == manifest.ProfileDivina { + switch profile { + case manifest.ProfileDivina: mime = mediatype.ReadiumDivinaManifest - } else if profile == manifest.ProfileAudiobook { + case manifest.ProfileAudiobook: mime = mediatype.ReadiumAudiobookManifest - } else { + default: continue } break From 3013e5c667322c6b916b3f4e37dea729acae1bdd Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 3 Nov 2025 13:12:09 -0800 Subject: [PATCH 13/16] update changelog --- CHANGELOG.MD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 516167e..6f8a938 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -4,7 +4,7 @@ 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.0] - 2025-10-27 +## [0.6.0] - 2025-11-03 ### Added From 4a9a99f5175ee469f4882992b63a6d4aa91e019e Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 3 Nov 2025 13:17:53 -0800 Subject: [PATCH 14/16] Update internal/cli/serve.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/cli/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 5f80873..95fd813 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -254,7 +254,7 @@ access to publications and prevent abuse or unauthorized access.`, return fmt.Errorf("failed creating JWKS auth provider: %w", err) } default: - return fmt.Errorf("invalid access mode %q, acceptable values: open, jwt, jwks", mode) + return fmt.Errorf("invalid access mode %q, acceptable values: base64, jwt, jwks", mode) } // Create server From 51aba6b504b7dcc0fdd4a5d0dedb61fe3c060d6e Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 3 Nov 2025 13:22:17 -0800 Subject: [PATCH 15/16] use http status constants --- pkg/serve/auth/encoded.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/serve/auth/encoded.go b/pkg/serve/auth/encoded.go index a4fec2b..60fd06e 100644 --- a/pkg/serve/auth/encoded.go +++ b/pkg/serve/auth/encoded.go @@ -3,6 +3,7 @@ package auth import ( "encoding/base64" "fmt" + "net/http" ) type B64EncodedAuthProvider struct{} @@ -10,9 +11,9 @@ type B64EncodedAuthProvider struct{} func (n *B64EncodedAuthProvider) Validate(token string) (string, int, error) { path, err := base64.RawURLEncoding.DecodeString(token) if err != nil { - return "", 400, fmt.Errorf("invalid base64url path: %w", err) + return "", http.StatusBadRequest, fmt.Errorf("invalid base64url path: %w", err) } - return string(path), 200, nil + return string(path), http.StatusOK, nil } func NewB64EncodedAuthProvider() *B64EncodedAuthProvider { From bfab343a60ebb7b7c16732222e9df32afc32d11c Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 3 Nov 2025 13:25:06 -0800 Subject: [PATCH 16/16] fix mod --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index a1922c7..eaaa902 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.31.16 github.com/aws/aws-sdk-go-v2/credentials v1.18.20 github.com/aws/aws-sdk-go-v2/service/s3 v1.89.1 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gorilla/mux v1.8.1 github.com/gotd/contrib v0.21.1 github.com/pkg/errors v0.9.1