Skip to content
Open
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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/<USER_TOKEN>` - 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**).
72 changes: 72 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url" // Added this import
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -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)))
}
})
}


29 changes: 29 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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()
Expand Down
Loading