diff --git a/README.md b/README.md index 8928c53..8e4f3ab 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,13 @@ ensure this connection is preserved. Without these rules, your Kobo will eventually lose its connection to `readeckobo`. +## 📚 Optional: Syncing Books + +`readeckobo` can optionally double as a reverse proxy for a self-hosted book service such as +[Grimmory](https://github.com/grimmory-tools/grimmory) or [Komga](https://komga.org), so a single +Kobo device can sync both articles and books. This is opt-in and off by default — see the +"Book Sync" section of [docs/CONFIG.md](docs/CONFIG.md) for configuration. + ## 🔒 A Quick Word on Security A little security goes a long way. diff --git a/config.yaml.example b/config.yaml.example index c5fc544..62c7169 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -6,3 +6,6 @@ readeck: users: - token: "a-very-secret-token-for-your-kobo" readeck_access_token: "your-plain-text-readeck-access-token" + # Only used if book_sync is enabled below. See docs/CONFIG.md. + # book_service_url: "https://grimmory.example.com/api/kobo/grimmory-token" +book_sync: false diff --git a/docs/CONFIG.md b/docs/CONFIG.md index a12e47b..49e0a8b 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -10,6 +10,7 @@ This document provides a detailed reference for all available configuration opti | `log_level` | Output verbosity. Options: `debug`, `info`, `warn`, `error`. | String | `info` | No | | `readeck.host` | The full base URL of your Readeck instance. | URL String | - | **Yes** | | `users` | A list of Kobo-to-Readeck user mappings. | List of Objects | - | **Yes** | +| `book_sync` | Proxy book sync to a self-hosted book service. See [Book Sync](#book-sync-optional) below. | Boolean | `false` | No | ## User Object @@ -19,6 +20,7 @@ Each entry in the `users` list maps a Kobo device identity to a Readeck account. | :--- | :--- | | `token` | A unique UUID used to identify the Kobo device. This is the **Plain Text** token generated by the `bin/generate-encrypted-token.sh` script. | | `readeck_access_token` | The API token for the Readeck account you want to sync. | +| `book_service_url` | Optional. This user's book service endpoint, used when `book_sync` is enabled. | ### How to get a Readeck API Token @@ -41,3 +43,25 @@ users: - token: "550e8400-e29b-41d4-a716-446655440000" # From generate-encrypted-token.sh readeck_access_token: "rdk_..." # From Readeck Profile ``` + +## Book Sync (optional) + +readeckobo can optionally proxy book sync to a self-hosted book service such as +[Grimmory](https://github.com/grimmory-tools/grimmory) or [Komga](https://komga.org), so a Kobo can +sync both articles (via readeckobo) and books through one of these services. Enable `book_sync`, +and set each user's `book_service_url` to the Kobo endpoint provided by that service: + +```yaml +book_sync: true + +users: + - token: "550e8400-e29b-41d4-a716-446655440000" + readeck_access_token: "rdk_..." + book_service_url: "https://grimmory.example.com/api/kobo/grimmory-token" +``` + +Point the Kobo's `api_endpoint` at `https://readeckobo.example.com/booksync/` - that's the +user's readeckobo `token` - and update your nginx config, see `nginx.conf.snippet`. + +Your proxy must set `X-Forwarded-Host` and `X-Forwarded-Proto`. The service must be configured to +forward to the Kobo Store (in Grimmory, **Forward to Kobo Store**). \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 988b494..7a712cf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -714,6 +714,78 @@ func (a *App) newReadeckClient(readeckToken string) (*readeck.Client, error) { return readeck.NewClient(a.Config.Readeck.Host, readeckToken, a.Logger, a.ReadeckHTTPClient) } +func (a *App) HandleBookSync(w http.ResponseWriter, r *http.Request) { + deviceToken := r.PathValue("deviceToken") + + upstream, ok := a.Config.ResolveBookSyncUpstream(deviceToken) + if !ok { + http.NotFound(w, r) + return + } + + target, err := url.Parse(upstream) + if err != nil { + a.Logger.Errorf("Error parsing book_sync endpoint %q: %v", upstream, err) + return + } + + rest := strings.TrimPrefix(r.URL.Path, "/booksync/"+deviceToken) + isInit := strings.HasSuffix(rest, "/v1/initialization") + + proxy := httputil.NewSingleHostReverseProxy(target) + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + req.URL.Path, req.URL.RawPath = rest, "" + originalDirector(req) + req.Host = target.Host + if isInit { + // So ModifyResponse below sees plaintext to rewrite. + req.Header.Set("Accept-Encoding", "identity") + } + } + + if isInit { + base := a.instapaperBase(r) + proxy.ModifyResponse = func(resp *http.Response) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if err := resp.Body.Close(); err != nil { + return err + } + + body = bytes.ReplaceAll(body, []byte("https://www.instapaper.com"), []byte(base)) + + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) + resp.Header.Del("Content-Encoding") + return nil + } + } + + proxy.ServeHTTP(w, r) +} + +func (a *App) instapaperBase(r *http.Request) string { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + if r.TLS == nil { + scheme = "http" + } else { + scheme = "https" + } + } + + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + + return scheme + "://" + host + "/instapaper-proxy/instapaper" +} + func (a *App) HandleDumpAndForward(w http.ResponseWriter, r *http.Request) { a.Logger.Debugf("Dumping request from %s", r.RemoteAddr) a.Logger.Debugf("Method: %s", r.Method) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 4465e34..b1f4f46 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" // Added this import + "strconv" "strings" "testing" @@ -843,4 +844,95 @@ func TestHandleConvertImage(t *testing.T) { }) } +func TestHandleBookSync(t *testing.T) { + t.Run("unknown token returns 404", func(t *testing.T) { + app := NewApp( + WithConfig(&config.Config{ + BookSync: true, + Users: []config.User{ + {Token: "device-token", ReadeckAccessToken: "rt", BookServiceURL: "https://grimmory.example.com/api/kobo/g1"}, + }, + }), + WithLogger(testLogger), + ) + + req := httptest.NewRequest(http.MethodGet, "/booksync/unknown-token/v1/library/sync", nil) + req.SetPathValue("deviceToken", "unknown-token") + rr := httptest.NewRecorder() + + app.HandleBookSync(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("non-initialization requests are proxied byte-for-byte", func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/kobo/grimmory-token/v1/library/sync" { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"entries": []}`)) + })) + defer upstream.Close() + + app := NewApp( + WithConfig(&config.Config{ + BookSync: true, + Users: []config.User{ + {Token: "device-token", ReadeckAccessToken: "rt", BookServiceURL: upstream.URL + "/api/kobo/grimmory-token"}, + }, + }), + WithLogger(testLogger), + ) + + req := httptest.NewRequest(http.MethodGet, "/booksync/device-token/v1/library/sync", nil) + req.SetPathValue("deviceToken", "device-token") + rr := httptest.NewRecorder() + + app.HandleBookSync(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code) + } + if got := rr.Body.String(); got != `{"entries": []}` { + t.Errorf("expected passthrough body, got %q", got) + } + }) + + t.Run("initialization response has instapaper.com rewritten", func(t *testing.T) { + goldenBody := `{"instapaper":"https://www.instapaper.com"}` + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(goldenBody)) + })) + defer upstream.Close() + + app := NewApp( + WithConfig(&config.Config{ + BookSync: true, + Users: []config.User{ + {Token: "device-token", ReadeckAccessToken: "rt", BookServiceURL: upstream.URL + "/api/kobo/grimmory-token"}, + }, + }), + WithLogger(testLogger), + ) + + req := httptest.NewRequest(http.MethodGet, "/booksync/device-token/v1/initialization", nil) + req.Host = "readeckobo.example.com" + req.SetPathValue("deviceToken", "device-token") + rr := httptest.NewRecorder() + + app.HandleBookSync(rr, req) + + want := `{"instapaper":"http://readeckobo.example.com/instapaper-proxy/instapaper"}` + if got := rr.Body.String(); got != want { + t.Errorf("body = %q, want %q", got, want) + } + if got := rr.Header().Get("Content-Length"); got != strconv.Itoa(len(want)) { + t.Errorf("Content-Length = %q, want %q", got, strconv.Itoa(len(want))) + } + }) +} + diff --git a/internal/config/config.go b/internal/config/config.go index 5c3e6a7..7d9f540 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ import ( type User struct { Token string `koanf:"token" validate:"required"` ReadeckAccessToken string `koanf:"readeck_access_token" validate:"required"` + BookServiceURL string `koanf:"book_service_url" validate:"omitempty,url"` } type ConfigReadeck struct { @@ -27,12 +28,16 @@ type Config struct { } `koanf:"server"` Users []User `koanf:"users" validate:"required,min=1,dive"` LogLevel string `koanf:"log_level" validate:"oneof=error warn info debug"` + BookSync bool `koanf:"book_sync"` } func (c *Config) Validate() error { validate := validator.New() err := validate.Struct(c) if err == nil { + if c.BookSync && !c.hasBookServiceURL() { + return fmt.Errorf("configuration validation failed: book_sync is enabled but no user has book_service_url set") + } return nil } @@ -44,6 +49,30 @@ func (c *Config) Validate() error { return err } +func (c *Config) hasBookServiceURL() bool { + for _, u := range c.Users { + if u.BookServiceURL != "" { + return true + } + } + + return false +} + +func (c *Config) ResolveBookSyncUpstream(deviceToken string) (string, bool) { + if !c.BookSync { + return "", false + } + + for _, u := range c.Users { + if u.Token == deviceToken && u.BookServiceURL != "" { + return u.BookServiceURL, true + } + } + + return "", false +} + func Load(path string) (*Config, error) { k := koanf.New(".") parser := yaml.Parser() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e1a2499..51a4cc0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,6 +121,39 @@ func TestLoad(t *testing.T) { yamlContent: "readeck: \"should be a map but is a string\"", wantErr: true, }, + { + name: "valid config with book_sync", + config: map[string]any{ + "readeck": map[string]any{ + "host": "https://readeck.example.com", + }, + "book_sync": true, + "users": []map[string]any{ + { + "token": "test-token", + "readeck_access_token": "test-readeck-token", + "book_service_url": "https://grimmory.example.com/api/kobo/grimmory-token", + }, + }, + }, + wantErr: false, + }, + { + name: "invalid config book_sync enabled without any book_service_url", + config: map[string]any{ + "readeck": map[string]any{ + "host": "https://readeck.example.com", + }, + "book_sync": true, + "users": []map[string]any{ + { + "token": "test-token", + "readeck_access_token": "test-readeck-token", + }, + }, + }, + wantErr: true, + }, } for _, tt := range tests { @@ -162,3 +195,61 @@ func TestLoad(t *testing.T) { }) } } + +func TestResolveBookSyncUpstream(t *testing.T) { + tests := []struct { + name string + cfg Config + deviceToken string + wantUpstream string + wantOK bool + }{ + { + name: "disabled returns false even with a configured url", + cfg: Config{ + BookSync: false, + Users: []User{ + {Token: "t1", BookServiceURL: "https://grimmory.example.com/api/kobo/g1"}, + }, + }, + deviceToken: "t1", + wantOK: false, + }, + { + name: "matching device token returns that user's endpoint", + cfg: Config{ + BookSync: true, + Users: []User{ + {Token: "t1", BookServiceURL: "https://grimmory.example.com/api/kobo/g1"}, + {Token: "t2", BookServiceURL: "https://grimmory.example.com/api/kobo/g2"}, + }, + }, + deviceToken: "t2", + wantUpstream: "https://grimmory.example.com/api/kobo/g2", + wantOK: true, + }, + { + name: "unknown device token returns false", + cfg: Config{ + BookSync: true, + Users: []User{ + {Token: "t1", BookServiceURL: "https://grimmory.example.com/api/kobo/g1"}, + }, + }, + deviceToken: "unknown-token", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotUpstream, gotOK := tt.cfg.ResolveBookSyncUpstream(tt.deviceToken) + if gotOK != tt.wantOK { + t.Errorf("ResolveBookSyncUpstream() ok = %v, want %v", gotOK, tt.wantOK) + } + if gotUpstream != tt.wantUpstream { + t.Errorf("ResolveBookSyncUpstream() upstream = %q, want %q", gotUpstream, tt.wantUpstream) + } + }) + } +} diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go index 97a8b42..f81d6ca 100644 --- a/internal/webserver/webserver.go +++ b/internal/webserver/webserver.go @@ -22,6 +22,10 @@ func ListenAndServe(port int, application *app.App, logger *logger.Logger) { mux.HandleFunc("/api/convert-image", application.HandleConvertImage) mux.HandleFunc("/instapaper-proxy/storeapi/v1/initialization", application.HandleDumpAndForward) + if application.Config.BookSync { + mux.HandleFunc("/booksync/{deviceToken}/", application.HandleBookSync) + } + // Catch-all for unimplemented routes mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { logger.Warnf("404 Not Found: URL=%s, Method=%s, Params=%v", r.URL.Path, r.Method, r.URL.Query()) diff --git a/nginx.conf.snippet b/nginx.conf.snippet index 3cbbd39..341b892 100644 --- a/nginx.conf.snippet +++ b/nginx.conf.snippet @@ -28,3 +28,14 @@ proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Prefix /instapaper-proxy/instapaper; } + + # Optional: if you enable `book_sync` in config.yaml, use this instead of the + # /instapaper-proxy/storeapi* blocks above (see docs/CONFIG.md). + # location /booksync/ { + # proxy_pass http://readeckobo-upstream; + # proxy_set_header Host $host; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_set_header X-Forwarded-Host $host; + # client_max_body_size 128M; + # proxy_buffer_size 128k; + # }