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 diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 932c3b2..6f8a938 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -4,6 +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.0] - 2025-11-03 + +### 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 diff --git a/go.mod b/go.mod index 73d3934..eaaa902 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,13 @@ go 1.24.2 require ( cloud.google.com/go/storage v1.57.1 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.5 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 diff --git a/go.sum b/go.sum index e9b5e1d..2f91d39 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= 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/serve.go b/internal/cli/serve.go index 87fb18c..95fd813 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 @@ -57,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 @@ -68,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 @@ -213,17 +217,58 @@ 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 "base64": + authProvider = auth.NewB64EncodedAuthProvider() + slog.Info("Operating in open access mode with base64url encoding (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: base64, 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) 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(), @@ -248,6 +293,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", "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") serveCmd.Flags().StringVar(&fileDirectoryFlag, "file-directory", "", "Local directory path to serve publications from") 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" diff --git a/pkg/serve/api.go b/pkg/serve/api.go index dd9dc84..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,43 +27,8 @@ 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) { - 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 +105,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 +174,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 +269,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 +293,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 +316,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..60fd06e --- /dev/null +++ b/pkg/serve/auth/encoded.go @@ -0,0 +1,21 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "net/http" +) + +type B64EncodedAuthProvider struct{} + +func (n *B64EncodedAuthProvider) Validate(token string) (string, int, error) { + path, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return "", http.StatusBadRequest, fmt.Errorf("invalid base64url path: %w", err) + } + return string(path), http.StatusOK, nil +} + +func NewB64EncodedAuthProvider() *B64EncodedAuthProvider { + return &B64EncodedAuthProvider{} +} 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/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, } 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 diff --git a/pkg/serve/router.go b/pkg/serve/router.go index e3b5fd1..b62791c 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() @@ -31,13 +36,26 @@ 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() - // TODO: publication loading middleware with pub.Use() - pub.Use(func(h http.Handler) http.Handler { + pub := r.PathPrefix("/webpub/{path}").Subrouter() + 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("", 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") diff --git a/pkg/serve/server.go b/pkg/serve/server.go index 35a8182..68593ac 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.NewB64EncodedAuthProvider() + } return &Server{ config: config, remote: remote,