Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ builds:
goarm:
- '7'
goamd64:
- v3
- v2
ldflags:
- -s -w

Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<domain>/<path>/manifest.json` is now `<domain>/webpub/<path>/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
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand Down
71 changes: 60 additions & 11 deletions internal/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/http"
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -57,24 +65,20 @@ 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
'http://localhost:15080/<filename in base64url encoding without padding>/manifest.json'.
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
Expand Down Expand Up @@ -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", "<jwt-shared-secret flag>")
}
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(),
Expand All @@ -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)")
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The flag description is very long and may be difficult to read in CLI help output. Consider breaking it into shorter text or moving detailed explanations to the Long description.

Copilot uses AI. Check for mistakes.

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")

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/version.go → internal/version/version.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package cli
package version

import (
"runtime/debug"
Expand Down
55 changes: 15 additions & 40 deletions pkg/serve/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ package serve
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"os"
"path"
"path/filepath"
"slices"
Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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())
Expand All @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions pkg/serve/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package auth

type AuthProvider interface {
Validate(token string) (string, int, error)
}
21 changes: 21 additions & 0 deletions pkg/serve/auth/encoded.go
Original file line number Diff line number Diff line change
@@ -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{}
}
Loading