Skip to content

Commit 56ed5a9

Browse files
authored
Revamp CLI serve syntax (#45)
1 parent ba1d43d commit 56ed5a9

5 files changed

Lines changed: 145 additions & 65 deletions

File tree

Dockerfile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,7 @@ LABEL org.opencontainers.image.source="https://github.com/readium/cli"
4646
ADD https://pagure.io/mailcap/raw/master/f/mime.types /etc/
4747

4848
# Add demo EPUBs to the container by default
49-
# This will go away soon!
50-
ADD --chown=nonroot:nonroot https://readium-playground-files.storage.googleapis.com/demo/moby-dick.epub /srv/publications/
49+
# ADD --chown=nonroot:nonroot https://readium-playground-files.storage.googleapis.com/demo/moby-dick.epub /srv/publications/
5150

5251
# Copy built Go binary
5352
COPY --from=builder "/app/readium" /opt/
@@ -57,4 +56,4 @@ EXPOSE 15080
5756
USER nonroot:nonroot
5857

5958
ENTRYPOINT ["/opt/readium"]
60-
CMD ["serve", "/srv/publications", "--address", "0.0.0.0"]
59+
CMD ["serve", "-s", "http,https", "--address", "0.0.0.0"]

internal/cli/serve.go

Lines changed: 102 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ package cli
22

33
import (
44
"context"
5-
"errors"
65
"fmt"
76
"log"
87
"net/http"
98
"os"
109
"path/filepath"
10+
"slices"
11+
"strings"
1112
"time"
1213

1314
"log/slog"
@@ -17,9 +18,11 @@ import (
1718
"github.com/aws/aws-sdk-go-v2/config"
1819
"github.com/aws/aws-sdk-go-v2/credentials"
1920
"github.com/aws/aws-sdk-go-v2/service/s3"
21+
"github.com/pkg/errors"
2022
"github.com/readium/cli/pkg/serve"
2123
"github.com/readium/cli/pkg/serve/client"
2224
"github.com/readium/go-toolkit/pkg/streamer"
25+
"github.com/readium/go-toolkit/pkg/util/url"
2326
"github.com/spf13/cobra"
2427
"google.golang.org/api/option"
2528
)
@@ -30,6 +33,10 @@ var bindAddressFlag string
3033

3134
var bindPortFlag uint16
3235

36+
var schemeFlag []string
37+
38+
var fileDirectoryFlag string
39+
3340
// Cloud-related flags
3441
var s3EndpointFlag string
3542
var s3RegionFlag string
@@ -45,28 +52,29 @@ var remoteArchiveCacheCount uint32
4552
var remoteArchiveCacheAll uint32
4653

4754
var serveCmd = &cobra.Command{
48-
Use: "serve <directory>",
55+
Use: "serve",
4956
Short: "Start a local HTTP server, serving a specified directory of publications",
5057
Long: `Start a local HTTP server, serving a specified directory of publications.
5158
5259
This command will start an HTTP serve listening by default on 'localhost:15080',
53-
serving all compatible files (EPUB, PDF, CBZ, etc.) found in the directory
60+
serving all compatible files (EPUB, PDF, CBZ, etc.) available from the enabled
61+
access schemes (file, http, https, s3, gs, or a local path if file scheme is enabled)
5462
as Readium Web Publications. To get started, the manifest can be accessed from
5563
'http://localhost:15080/<filename in base64url encoding without padding>/manifest.json'.
5664
This file serves as the entry point and contains metadata and links to the rest
5765
of the files that can be accessed for the publication.
5866
59-
For debugging purposes, the server also exposes a '/list.json' endpoint that
60-
returns a list of all the publications found in the directory along with their
61-
encoded paths. This will be replaced by an OPDS 2 feed in a future release.
67+
If local file access is enabled, the server also exposes a '/list.json' endpoint that,
68+
for debugging purposes, returns a list of all the publications found in the directory
69+
along with their encoded paths. This will be replaced by an OPDS 2 feed (or similar)
70+
in a future release.
6271
63-
Note: This server is not meant for production usage, and should not be exposed
64-
to the internet except for testing/debugging purposes.`,
72+
Note: Take caution before exposing this server on the internet. It does not
73+
implement any authentication, and may have more access to files than expected.`,
6574
Args: func(cmd *cobra.Command, args []string) error {
66-
if len(args) == 0 {
67-
return errors.New("expects a directory path to serve publications from")
68-
} else if len(args) > 1 {
69-
return errors.New("accepts a directory path")
75+
if len(args) > 0 {
76+
// For users migrating from previous versions of the CLI
77+
return errors.New("no arguments expected, base directory for local files is now set with a flag")
7078
}
7179
return nil
7280
},
@@ -78,16 +86,37 @@ to the internet except for testing/debugging purposes.`,
7886
// occurs.
7987
cmd.SilenceUsage = true
8088

81-
path := filepath.Clean(args[0])
82-
fi, err := os.Stat(path)
83-
if err != nil {
84-
if os.IsNotExist(err) {
85-
return fmt.Errorf("given directory %s does not exist", path)
89+
// Validate schemes
90+
schemes := make([]url.Scheme, len(schemeFlag))
91+
for i, v := range schemeFlag {
92+
lowerScheme := url.Scheme(strings.ToLower(v)) // Accomodate for wrong capitalization
93+
switch lowerScheme {
94+
case url.SchemeFile, url.SchemeHTTP, url.SchemeHTTPS, url.SchemeS3, url.SchemeGS:
95+
schemes[i] = lowerScheme
96+
default:
97+
return fmt.Errorf("invalid scheme %q, acceptable values: file, http, https, s3, gs", v)
8698
}
87-
return fmt.Errorf("failed to stat %s: %w", path, err)
8899
}
89-
if !fi.IsDir() {
90-
return fmt.Errorf("given path %s is not a directory", path)
100+
101+
if fileDirectoryFlag != "" {
102+
if !slices.Contains(schemes, url.SchemeFile) {
103+
slog.Warn("local directory specified, but file scheme is not enabled")
104+
}
105+
106+
path := filepath.Clean(fileDirectoryFlag)
107+
fi, err := os.Stat(path)
108+
if err != nil {
109+
if os.IsNotExist(err) {
110+
return fmt.Errorf("given directory %s does not exist", path)
111+
}
112+
return fmt.Errorf("failed to stat %s: %w", path, err)
113+
}
114+
if !fi.IsDir() {
115+
return fmt.Errorf("given path %s is not a directory", path)
116+
}
117+
fileDirectoryFlag = path
118+
} else if slices.Contains(schemes, url.SchemeFile) {
119+
return fmt.Errorf("file scheme is enabled, but no local directory was specified with the --file-directory flag")
91120
}
92121

93122
// Log level
@@ -98,51 +127,67 @@ to the internet except for testing/debugging purposes.`,
98127
}
99128

100129
// Set up remote publication retrieval clients
101-
remote := serve.Remote{}
130+
remote := serve.Remote{
131+
LocalDirectory: fileDirectoryFlag,
132+
}
133+
134+
// 30 seconds to set up remote retrieval clients
135+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
136+
defer cancel()
102137

103138
// S3
104-
options := []func(*config.LoadOptions) error{
105-
config.WithRegion(s3RegionFlag),
106-
config.WithRequestChecksumCalculation(0),
107-
config.WithResponseChecksumValidation(0),
108-
// TODO: look into custom HTTP client, user-agent
109-
}
110-
if s3AccessKeyFlag != "" && s3SecretKeyFlag != "" {
111-
options = append(options, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(s3AccessKeyFlag, s3SecretKeyFlag, "")))
112-
}
113-
cfg, err := config.LoadDefaultConfig(context.Background(), options...)
114-
if err != nil {
115-
log.Fatal(err)
116-
}
117-
_, err = cfg.Credentials.Retrieve(context.Background())
118-
if err == nil {
119-
remote.S3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
120-
if s3EndpointFlag != "" {
121-
o.BaseEndpoint = aws.String(s3EndpointFlag)
122-
}
123-
o.DisableLogOutputChecksumValidationSkipped = true // Non-AWS S3 tends not to support this and it causes logspam
124-
o.UsePathStyle = s3UsePathStyleFlag
125-
})
126-
} else {
127-
slog.Warn("S3 credentials retrieval failed, S3 support will be disabled", "error", err)
139+
if slices.Contains(schemes, url.SchemeS3) {
140+
options := []func(*config.LoadOptions) error{
141+
config.WithRegion(s3RegionFlag),
142+
config.WithRequestChecksumCalculation(0),
143+
config.WithResponseChecksumValidation(0),
144+
// TODO: look into custom HTTP client, user-agent
145+
}
146+
if s3AccessKeyFlag != "" && s3SecretKeyFlag != "" {
147+
options = append(options, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(s3AccessKeyFlag, s3SecretKeyFlag, "")))
148+
}
149+
cfg, err := config.LoadDefaultConfig(ctx, options...)
150+
if err != nil {
151+
log.Fatal(err)
152+
}
153+
_, err = cfg.Credentials.Retrieve(ctx)
154+
if err == nil {
155+
remote.S3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
156+
if s3EndpointFlag != "" {
157+
o.BaseEndpoint = aws.String(s3EndpointFlag)
158+
}
159+
o.DisableLogOutputChecksumValidationSkipped = true // Non-AWS S3 tends not to support this and it causes logspam
160+
o.UsePathStyle = s3UsePathStyleFlag
161+
})
162+
} else {
163+
return fmt.Errorf("S3 credentials retrieval failed: %w", err)
164+
}
165+
} else if s3AccessKeyFlag != "" || s3SecretKeyFlag != "" || s3EndpointFlag != "" {
166+
slog.Warn("S3-related flags are set, but S3 scheme is not enabled")
128167
}
129168

130169
// GCS
131-
opts := []option.ClientOption{
132-
option.WithScopes(storage.ScopeReadOnly),
133-
storage.WithJSONReads(),
134-
// option.WithUserAgent(TODO),
135-
// TODO: look into more efficient transport (HTTP client)
136-
}
137-
remote.GCS, err = storage.NewClient(context.Background(), opts...)
138-
if err != nil {
139-
slog.Warn("GCS client creation failed, GCS support will be disabled", "error", err)
170+
var err error
171+
if slices.Contains(schemes, url.SchemeGS) {
172+
opts := []option.ClientOption{
173+
option.WithScopes(storage.ScopeReadOnly),
174+
storage.WithJSONReads(),
175+
// option.WithUserAgent(TODO),
176+
// TODO: look into more efficient transport (HTTP client)
177+
}
178+
remote.GCS, err = storage.NewClient(ctx, opts...)
179+
if err != nil {
180+
return fmt.Errorf("GCS client creation failed: %w", err)
181+
}
140182
}
141183

184+
// HTTP/HTTPS
142185
remote.HTTP, err = client.NewHTTPClient(httpAuthorizationFlag)
143186
if err != nil {
144187
slog.Warn("HTTP client creation failed, HTTP support will be disabled", "error", err)
145188
}
189+
remote.HTTPEnabled = slices.Contains(schemes, url.SchemeHTTP)
190+
remote.HTTPSEnabled = slices.Contains(schemes, url.SchemeHTTPS)
146191

147192
// Remote archive streaming tweaks
148193
remote.Config.CacheCountThreshold = int64(remoteArchiveCacheCount)
@@ -153,7 +198,6 @@ to the internet except for testing/debugging purposes.`,
153198
// Create server
154199
pubServer := serve.NewServer(serve.ServerConfig{
155200
Debug: debugFlag,
156-
BaseDirectory: path,
157201
JSONIndent: indentFlag,
158202
InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag),
159203
}, remote)
@@ -180,12 +224,15 @@ to the internet except for testing/debugging purposes.`,
180224
func init() {
181225
rootCmd.AddCommand(serveCmd)
182226

227+
serveCmd.Flags().StringSliceVarP(&schemeFlag, "scheme", "s", []string{"file"}, "Scheme(s) to enable for accessing content. Acceptable values: file, http, https, s3, gs")
183228
serveCmd.Flags().StringVarP(&bindAddressFlag, "address", "a", "localhost", "Address to bind the HTTP server to")
184229
serveCmd.Flags().Uint16VarP(&bindPortFlag, "port", "p", 15080, "Port to bind the HTTP server to")
185230
serveCmd.Flags().StringVarP(&indentFlag, "indent", "i", "", "Indentation used to pretty-print JSON files")
186231
serveCmd.Flags().Var(&inferA11yFlag, "infer-a11y", "Infer accessibility metadata: no, merged, split")
187232
serveCmd.Flags().BoolVarP(&debugFlag, "debug", "d", false, "Enable debug mode")
188233

234+
serveCmd.Flags().StringVar(&fileDirectoryFlag, "file-directory", "", "Local directory path to serve publications from")
235+
189236
serveCmd.Flags().StringVar(&s3EndpointFlag, "s3-endpoint", "", "Custom S3 endpoint URL")
190237
serveCmd.Flags().StringVar(&s3RegionFlag, "s3-region", "auto", "S3 region")
191238
serveCmd.Flags().StringVar(&s3AccessKeyFlag, "s3-access-key", "", "S3 access key")

pkg/serve/api.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,15 @@ type demoListItem struct {
3434
Path string `json:"path"`
3535
}
3636

37+
// TODO: replace with OPDS or something better
3738
func (s *Server) demoList(w http.ResponseWriter, req *http.Request) {
38-
fi, err := os.ReadDir(s.config.BaseDirectory)
39+
if s.remote.LocalDirectory == "" {
40+
slog.Warn("demo publication list requested, but no local directory configured")
41+
w.WriteHeader(404)
42+
return
43+
}
44+
45+
fi, err := os.ReadDir(s.remote.LocalDirectory)
3946
if err != nil {
4047
slog.Error("failed reading publications directory", "error", err)
4148
w.WriteHeader(500)
@@ -72,8 +79,11 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ
7279
InferA11yMetadata: s.config.InferA11yMetadata,
7380
HttpClient: s.remote.HTTP,
7481
}
82+
if !s.remote.AcceptsScheme(u.Scheme()) {
83+
return nil, remote, errors.New("unacceptable scheme " + u.Scheme().String())
84+
}
7585
if u.IsFile() {
76-
path, err := url.FromFilepath(filepath.Join(s.config.BaseDirectory, path.Clean(u.Path())))
86+
path, err := url.FromFilepath(filepath.Join(s.remote.LocalDirectory, path.Clean(u.Path())))
7787
if err != nil {
7888
return nil, remote, errors.Wrap(err, "failed creating URL from filepath")
7989
}

pkg/serve/helpers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ var compressableMimes = []string{
4949
"application/font-woff",
5050
"font/x-woff",
5151
"application/vnd.ms-fontobject",
52+
mediatype.OPF.String(),
53+
mediatype.NCX.String(),
54+
mediatype.XML.String(),
55+
mediatype.JSON.String(),
5256
}
5357

5458
func conformsToAsMimetype(conformsTo manifest.Profiles) mediatype.MediaType {

pkg/serve/server.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,38 @@ import (
1010
"github.com/readium/cli/pkg/serve/cache"
1111
"github.com/readium/go-toolkit/pkg/archive"
1212
"github.com/readium/go-toolkit/pkg/streamer"
13+
"github.com/readium/go-toolkit/pkg/util/url"
1314
)
1415

1516
type Remote struct {
16-
S3 *s3.Client // AWS S3-compatible storage
17-
GCS *storage.Client // Google Cloud Storage
18-
HTTP *http.Client // HTTP-requested storage
19-
Config archive.RemoteArchiveConfig
17+
LocalDirectory string // Local directory base path
18+
S3 *s3.Client // AWS S3-compatible storage
19+
GCS *storage.Client // Google Cloud Storage
20+
HTTP *http.Client // HTTP-requested storage
21+
HTTPEnabled bool // Whether HTTP is enabled
22+
HTTPSEnabled bool // Whether HTTPS is enabled
23+
Config archive.RemoteArchiveConfig
24+
}
25+
26+
func (r Remote) AcceptsScheme(scheme url.Scheme) bool {
27+
switch scheme {
28+
case url.SchemeFile:
29+
return r.LocalDirectory != ""
30+
case url.SchemeS3:
31+
return r.S3 != nil
32+
case url.SchemeGS:
33+
return r.GCS != nil
34+
case url.SchemeHTTP:
35+
return r.HTTPEnabled && r.HTTP != nil
36+
case url.SchemeHTTPS:
37+
return r.HTTPSEnabled && r.HTTP != nil
38+
default:
39+
return false
40+
}
2041
}
2142

2243
type ServerConfig struct {
2344
Debug bool
24-
BaseDirectory string
2545
JSONIndent string
2646
InferA11yMetadata streamer.InferA11yMetadata
2747
}

0 commit comments

Comments
 (0)