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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
run: cargo build --bin anki-sync-server

- name: Test
run: cargo test -p sync-storage-config -p sync-storage-backends -p sync-storage-api
run: cargo test -p sync-storage-config -p sync-storage-backends -p sync-platform-api

docker-smoke-test:
runs-on: ubuntu-latest
Expand Down
217 changes: 118 additions & 99 deletions CLAUDE.md

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ rust-version = "1.80"

[workspace]
members = [
"sync-storage-api",
"sync-platform-api",
"sync-storage-backends",
"sync-storage-config",
"sync-storage-server",
Expand Down Expand Up @@ -53,7 +53,7 @@ anki_io = { path = "rslib/io" }
anki_process = { path = "rslib/process" }
anki_proto = { path = "rslib/proto" }
anki_proto_gen = { path = "rslib/proto_gen" }
sync-storage-api = { path = "sync-storage-api" }
sync-platform-api = { path = "sync-platform-api" }
sync-storage-backends = { path = "sync-storage-backends" }
sync-storage-config = { path = "sync-storage-config" }
sync-storage-server = { path = "sync-storage-server" }
Expand Down
131 changes: 38 additions & 93 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Fork of [`ankitects/anki@25.09`](https://github.com/ankitects/anki/tree/25.09) r
├── Cargo.toml ← workspace root — the only file NOT from upstream
├── Cargo.lock ← copied from upstream for reproducible builds
├── README.md ← this file
├── sync-storage-api/ ← StorageBackend trait (no cloud deps)
├── sync-platform-api/ ← AuthProvider, BackendResolver, StorageBackend traits
│ └── src/lib.rs
├── sync-storage-backends/ ← StorageBackendFactory + per-provider impls
│ └── src/
Expand Down Expand Up @@ -50,9 +50,9 @@ Fork of [`ankitects/anki@25.09`](https://github.com/ankitects/anki/tree/25.09) r

| Crate | Origin | Touches upgrade? |
|-------------------------|---------------------|----------------------------------------------|
| `sync-storage-api` | ours | Never |
| `sync-platform-api` | ours | Never |
| `sync-storage-backends` | ours | Never |
| `sync-storage-config` | ours | Never |
| `sync-storage-config` | ours (transitional) | Never |
| `sync-storage-server` | ours | Only if rslib's public types change |
| `rslib/` and sub-crates | upstream (verbatim) | Yes — replaced by fork script, then re-patch |

Expand All @@ -61,15 +61,15 @@ Fork of [`ankitects/anki@25.09`](https://github.com/ankitects/anki/tree/25.09) r
`rslib/` is replaced wholesale by the fork script. After each replacement, **four files** must be
patched to wire in our auth and storage providers. All other rslib files stay verbatim.

**Design:** two traits from `sync-storage-api` are injected at server startup; rslib never imports
`sync-storage-config` or `sync-storage-backends` directly. `SyncMode` logic lives entirely in
`sync-storage-server`. See the [upgrade section](#upgrading-to-a-new-anki-release) for how to re-apply.
**Design:** three traits from `sync-platform-api` are injected at server startup via `SimpleServer::new(base_folder, auth, resolver)`;
rslib never imports `sync-storage-config` or `sync-storage-backends` directly.
See the [upgrade section](#upgrading-to-a-new-anki-release) for how to re-apply after an upstream sync.

#### `rslib/Cargo.toml` — add one dependency

```toml
# in [dependencies]
sync-storage-api.workspace = true
sync-platform-api.workspace = true
# also add anyhow to [dev-dependencies]
```

Expand All @@ -89,7 +89,7 @@ println!("{}", sync_storage_server::run());
#### `rslib/src/sync/http_server/mod.rs`

- `SimpleServer` struct: replace `mode: SyncMode` with `auth: Arc<dyn AuthProvider>` and
`backend_resolver: Arc<dyn BackendResolver>` (both from `sync_storage_api`)
`backend_resolver: Arc<dyn BackendResolver>` (both from `sync_platform_api`)
- `SyncServerConfig`: remove `mode: SyncMode` field (SYNC_MODE is read in `sync-storage-server`)
- `SimpleServer::new()`: takes `auth` + `backend_resolver` instead of `mode`
- `SimpleServer::make_server()`: takes pre-built `Arc<SimpleServer>` as second argument; no sidecar spawn (that moves to `sync-storage-server`)
Expand Down Expand Up @@ -144,7 +144,7 @@ docker build -t anki-cloud-sync:local .
## Standalone mode

No database or cloud credentials are required. Users are defined via `SYNC_USER*` env vars.
Behaves identically to the original [Anki's rslib sync server](https://github.com/ankitects/anki/tree/master/rslib). Default mode (`SYNC_MODE=standalone`).
Behaves identically to the original [Anki's rslib sync server](https://github.com/ankitects/anki/tree/master/rslib).

### Run

Expand Down Expand Up @@ -190,84 +190,34 @@ On `/sync/hostKey` (Anki login):

On subsequent sync requests: looks up `hkey` in in-memory session map.

## Cloud mode
## Platform implementations

Backed by a shared SQLite database and per-user cloud storage (Google Drive, etc.).
Set `SYNC_MODE=cloud`.
The binary in this repo runs in standalone mode only. Cloud deployments (or any other
deployment target) supply their own `AuthProvider` and `BackendResolver` by implementing the
three traits from `sync-platform-api`:

### Run

#### Local build

```bash
SYNC_MODE=cloud \
DATABASE_URL=file:/path/to/anki-cloud.db \
TOKEN_ENCRYPTION_KEY=<64-hex-chars> \
GOOGLE_CLIENT_ID=<client-id> \
GOOGLE_CLIENT_SECRET=<client-secret> \
SYNC_INTERNAL_TOKEN=<secret-token> \
./target/debug/anki-sync-server
# Listens on 0.0.0.0:8080 (sync) and 127.0.0.1:8081 (internal API) by default.
```

#### Docker

```bash
docker run \
-e SYNC_MODE=cloud \
-e DATABASE_URL=file:/data/anki-cloud.db \
-e TOKEN_ENCRYPTION_KEY=<64-hex-chars> \
-e GOOGLE_CLIENT_ID=<client-id> \
-e GOOGLE_CLIENT_SECRET=<client-secret> \
-e SYNC_INTERNAL_HOST=0.0.0.0 \
-e SYNC_INTERNAL_TOKEN=<secret-token> \
-v /path/to/data:/data \
-p 8080:8080 \
-p 8081:8081 \
anki-cloud-sync:local
```rust
pub trait AuthProvider: Send + Sync {
fn authenticate(&self, username: &str, password: &str) -> Result<(String, String)>;
fn lookup_by_hkey(&self, hkey: &str) -> Result<String>;
}

pub trait BackendResolver: Send + Sync {
fn resolve_for_user(&self, username: &str) -> Result<Box<dyn StorageBackend>>;
}

pub trait StorageBackend: Send + Sync {
fn fetch(&self, user: &str, dest: &Path) -> Result<()>;
fn commit(&self, user: &str, src: &Path) -> Result<()>;
}
```

### Environment variables

| Variable | Default | Description |
|------------------------|-----------------|----------------------------------------------------------------------------------------------|
| `DATABASE_URL` | — | Path to the shared SQLite database (e.g. `file:/data/anki-cloud.db`) |
| `TOKEN_ENCRYPTION_KEY` | — | 32-byte AES-256 key used to decrypt OAuth tokens in the DB (64 hex chars or 44 base64 chars) |
| `GOOGLE_CLIENT_ID` | — | Google OAuth2 client ID — used to exchange refresh tokens for fresh access tokens |
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 client secret |
| `SYNC_BASE` | `~/.syncserver` | Directory for temporary user collection files during sync |
| `SYNC_HOST` | `0.0.0.0` | Bind address for the Anki sync protocol |
| `SYNC_PORT` | `8080` | Port for the Anki sync protocol |
| `SYNC_INTERNAL_HOST` | `127.0.0.1` | Bind address for the internal REST API — set `0.0.0.0` in Docker |
| `SYNC_INTERNAL_PORT` | `8081` | Port for the internal REST API (see [Internal API](#internal-api)) |
| `SYNC_INTERNAL_TOKEN` | — | Bearer token for internal API requests; if unset, internal API is disabled |

### Authentication

Users authenticate with their email address and a per-user sync password set via the web UI.
No `SYNC_USER*` env vars are needed.

On `/sync/hostKey` (Anki login):

1. Verifies `email` + `password` against `users.sync_password_hash` db table (bcrypt, timing-safe)
2. Derives `hkey = SHA1(email:password)` and upserts it into `users_sync_state.sync_key` db table
3. Returns `hkey` to Anki client as session token

On subsequent sync requests (hkey in `anki-sync` header):
Wire them into the server with `SimpleServer::new(base_folder, auth, resolver)` and call
`sync_storage_server::run()`. The platform crate owns all DB lookups, token decryption, and OAuth
token exchange — this repo has no knowledge of any of those.

1. Looks up hkey in in-memory session map
2. If not found (server restart or different instance): queries `users_sync_state` by hkey to re-hydrate

### Per-request storage lookup

On each sync operation that requires storage access (open, finish, upload), the sync server:

1. Looks up `storage_connections` in the shared SQLite DB, joining on `users.email`
2. Decrypts the stored `oauth_refresh_token` (AES-256-GCM) — skipped for `provider = "local"`
3. Exchanges the refresh token for a fresh Google access token via `https://oauth2.googleapis.com/token`
4. Passes the access token to `StorageBackendFactory` to create the appropriate backend

This makes each sync server instance stateless — no per-user config in memory, safe to run behind a load balancer.
The `anki-cloud` repo contains the reference cloud platform implementation (`sync-platform-cloud`).
See [ADR-0014](docs/decisions/0014-introduce-sync-platform-api-boundary.md) for the rationale.

## Internal API

Expand Down Expand Up @@ -338,20 +288,16 @@ curl -s -X POST "http://localhost:8081/internal/v1/decks/1234567890/notes/bulk"
## Test

```bash
cargo test -p anki-sync-server
```

To run only the sync-storage-config tests:

```bash
cargo test -p sync-storage-config
cargo test -p sync-platform-api
cargo test -p sync-storage-backends
cargo test -p sync-storage-server
```

## Versioning

### Crate versions

Custom crates (`sync-storage-api`, `sync-storage-backends`, `sync-storage-config`,
Custom crates (`sync-platform-api`, `sync-storage-backends`, `sync-storage-config`,
`sync-storage-server`) are versioned as `<anki-major>.<anki-minor>.<anki-patch>` in semver form —
e.g. Anki `25.09` → `25.9.0`, Anki `25.09.2` → `25.9.2`. Leading zeros are dropped (Cargo strips
them anyway). The `-rX` revision counter is **not** baked into the crate version to avoid collisions
Expand Down Expand Up @@ -394,7 +340,7 @@ that needs updating.
Quick checklist:

- [ ] `sync-storage-*/Cargo.toml` — bump `version` to match new Anki version (e.g. `25.9.2`)
- [ ] `rslib/Cargo.toml` — `sync-storage-api` in `[dependencies]`, `anyhow` in `[dev-dependencies]`
- [ ] `rslib/Cargo.toml` — `sync-platform-api` in `[dependencies]`, `anyhow` in `[dev-dependencies]`
- [ ] `rslib/sync/Cargo.toml` — `sync-storage-server` in both platform dependency blocks
- [ ] `rslib/sync/main.rs` — calls `sync_storage_server::run()` not `SimpleServer::run()`
- [ ] `rslib/src/sync/http_server/mod.rs` — DI fields, simplified auth methods, `SidecarUserHandle`, `derive_hkey`, `base_folder()`, updated `new()` / `make_server()` signatures
Expand All @@ -408,7 +354,6 @@ Quick checklist:

```bash
# zero-tolerance checks — all must return empty
grep -r 'SyncMode' rslib/src/
grep -r 'sync_storage_config' rslib/src/
grep -r 'sync_storage_backends' rslib/src/
ls rslib/src/sync/http_server/internal_*.rs 2>/dev/null
Expand All @@ -423,7 +368,7 @@ cargo build --bin anki-sync-server
cargo test -p anki

# custom crate unit tests
cargo test -p sync-storage-config -p sync-storage-backends -p sync-storage-server
cargo test -p sync-platform-api -p sync-storage-backends -p sync-storage-server
```

All tests must pass before tagging.
Expand Down
86 changes: 86 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1 +1,87 @@
# TODO

---

## [ARCH] Introduce sync-platform-api — clean platform boundary

### Background

The sync server originally bundled two concerns that must be separated to support multiple
deployment targets (cloud Docker, Android embedded service):

1. **Core sync protocol** — `rslib/` (upstream, verbatim), `sync-storage-backends/` (Google Drive
+ local impls), `sync-storage-server/` (composition root). These are platform-agnostic.

2. **Platform-specific glue** — `sync-storage-config/` (SQLite queries, AES-256-GCM token
decrypt, bcrypt auth, OAuth HTTP exchange). This knows about a specific DB schema and
credential storage mechanism. It must be extracted.

The trait boundary already exists in `sync-platform-api`: `AuthProvider`, `BackendResolver`,
`StorageBackend`. This is the **only** public contract that external deployment targets depend on.

After this refactor:
- `anki-cloud-sync` knows nothing about SQLite schemas, AES keys, JNI, or Android.
- `anki-cloud` owns its own `sync-platform-cloud` crate (SQLite + OAuth + AES).
- `anki-cloud-android` owns its own `sync-platform-android` crate (Room + Android Credential
Manager + JNI callbacks).

### Tasks

**1. ✅ Rename `sync-storage-api` → `sync-platform-api`**

- Renamed directory and crate name in `sync-platform-api/Cargo.toml`
- Updated workspace `Cargo.toml`: replaced `sync-storage-api` entry with `sync-platform-api`
- Updated all import paths across the workspace (9 files)

**2. Delete `sync-storage-config` crate**

Blocked on `anki-cloud` team landing `sync-platform-cloud` first (it takes ownership of all DB
queries, token decryption, OAuth exchange, and bcrypt auth currently in `sync-storage-config`).

Sequencing:

```
anki-cloud-sync (rename + strip) → TAG
├── anki-cloud (sync-platform-cloud) ┐ parallel
└── anki-cloud-android (sync-platform-android) ┘
anki-cloud-sync (delete sync-storage-config) → TAG
anki-cloud (bump pinned tag)
```

Once `anki-cloud` signals ready:
- Remove `sync-storage-config/` directory
- Remove from workspace `Cargo.toml`
- Remove from `sync-storage-server/Cargo.toml` dependencies

**3. ✅ Strip Cloud impls from `sync-storage-server`**

- Deleted `CloudAuthProvider` from `sync-storage-server/src/auth.rs`
- Deleted `CloudBackendResolver` from `sync-storage-server/src/resolver.rs`
- Removed `SyncMode` enum, `mode_from_env()`, and Cloud branch from `sync-storage-server/src/lib.rs`
- Removed `sync-storage-config` dep from `sync-storage-server/Cargo.toml`
- `sync-storage-server` now retains only `StandaloneAuthProvider` + `StandaloneBackendResolver`

**4. ✅ Update docs**

- Updated CLAUDE.md, README.md, TODO.md, added ADR-0014

### Acceptance criteria

- `cargo build --bin anki-sync-server` succeeds (standalone mode)
- `cargo test -p sync-platform-api` passes
- `cargo test -p sync-storage-backends` passes
- No `sync_storage_config` imports anywhere in the workspace
- No JNI or SQLite schema references anywhere in the workspace

---

## [BACKLOG] Expose `SimpleServer` as a stable library interface

Currently `SimpleServer::new(base_folder, auth, resolver)` is in `rslib` (upstream, no-edit).
For external callers (anki-cloud-android's `sync-server-jni`) to call it, a thin shim crate
may be needed that re-exports it with a stable API surface.

Defer until anki-cloud-android needs it — implement only if the existing import path is
impractical from an external crate.
Loading
Loading