Skip to content

Repository files navigation

anki-cloud-sync

Stateless Anki sync server backed by user-owned cloud storage (Google Drive, Dropbox, S3). Fork of ankitects/anki@25.09 rslib, AGPLv3.

What's in here?

/
├── Cargo.toml               ← workspace root — the only file NOT from upstream
├── Cargo.lock               ← copied from upstream for reproducible builds
├── README.md                ← this file
├── sync-platform-api/       ← AuthProvider, BackendResolver, StorageBackend traits
│   └── src/lib.rs
├── sync-storage-backends/   ← StorageBackendFactory + per-provider impls
│   └── src/
│       ├── lib.rs           ← StorageBackendFactory
│       └── backends/
│           ├── local.rs        ← no-op (local filesystem, default)
│           └── google_drive.rs ← Google Drive impl
├── sync-storage-config/         ← per-request DB lookup + token management (our code)
│   └── src/lib.rs           ← fetch_storage_connection, exchange_refresh_token
├── .version                 ← upstream version string (e.g. "25.09"), read by rslib/src/version.rs
├── out/                     ← generated by anki_proto build script (gitignored)
│   ├── pylib/anki/_backend_generated.py   ← Python proto bindings
│   └── ts/lib/generated/                  ← TypeScript proto bindings
├── ftl/                     ← verbatim copy of ankitects/anki ftl/ + submodules (required by anki_i18n build)
│   ├── core/                ← English core templates
│   ├── core-repo/           ← git submodule: core translations
│   ├── qt/                  ← English Qt templates
│   └── qt-repo/             ← git submodule: Qt translations
├── proto/                   ← verbatim copy of ankitects/anki proto/ (required by anki_proto build)
└── rslib/                   ← verbatim copy of ankitects/anki rslib/ at 25.09
    ├── Cargo.toml           ← the `anki` library crate
    ├── build.rs             ← generates Rust code from .proto files (requires protoc)
    ├── src/
    │   └── sync/
    │       └── http_server/ ← ADR-0003 hook points (minimal changes — upstream diff kept small)
    ├── sync/
    │   ├── Cargo.toml       ← `anki-sync-server` binary crate
    │   └── main.rs
    ├── proto/               ← anki_proto crate (.proto definitions)
    ├── proto_gen/           ← anki_proto_gen (build-dep, not a workspace member)
    ├── i18n/                ← anki_i18n
    ├── io/                  ← anki_io
    └── process/             ← anki_process

Our crates vs upstream

Crate Origin Touches upgrade?
sync-platform-api ours Never
sync-storage-backends 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

rslib delta — what we keep in upstream code

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: 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 for how to re-apply after an upstream sync.

rslib/Cargo.toml — add one dependency

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

rslib/sync/Cargo.toml — add binary dependency

# in [target.'cfg(...)'.dependencies] (both windows and non-windows blocks)
sync-storage-server = { workspace = true }

rslib/sync/main.rs — call our run() instead of SimpleServer::run()

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_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)
  • with_authenticated_user(): single code path — calls self.auth.lookup_by_hkey()
  • get_host_key(): single code path — calls self.auth.authenticate()
  • ensure_user(): takes backend_resolver: Arc<dyn BackendResolver> instead of mode
  • get_or_create_sidecar_user(): same — takes backend_resolver instead of mode
  • Add SidecarUserHandle<'a> struct (opaque handle exposing with_col / with_col_and_commit)
  • Add SimpleServer::with_sidecar_user() pub method (used by sidecar handlers in sync-storage-server)
  • Add pub fn derive_hkey(s: &str) -> String (used by StandaloneAuthProvider)
  • Add pub fn base_folder(&self) -> &Path accessor
  • Remove SimpleServer::run() (replaced by sync_storage_server::run())
  • Remove mod internal_handlers and mod internal_server declarations

rslib/src/sync/http_server/user.rs

  • User struct: replace mode: SyncMode with backend_resolver: Arc<dyn BackendResolver>
  • open_collection(): replace 20-line match block with two lines: resolve_for_user() + backend.fetch()
  • with_col_and_commit(): replace 20-line match block with two lines: resolve_for_user() + backend.commit()

rslib/src/sync/http_server/handlers.rs

  • finish(): replace 20-line duplicated match block with two lines: resolve_for_user() + backend.commit()
  • upload(): same

rslib/src/sync/collection/tests.rs

  • Replace mode: SyncMode::Standalone in SyncServerConfig literal with inline TestAuthProvider and LocalBackendResolver test doubles
  • Update make_server(config) call to make_server(config, server) (pass pre-built Arc<SimpleServer>)

Prerequisites

  • Rust stable ≥ 1.80 (rustup update stable)
  • Protocol Buffers compiler: brew install protobuf (macOS) or apt install protobuf-compiler (Linux)

Build

Local

cargo build --bin anki-sync-server

The binary lands at target/debug/anki-sync-server.

Docker

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.

Run

Local build

SYNC_USER1=alice@example.com:secret \
  ./target/debug/anki-sync-server
# Listens on 0.0.0.0:8080 by default.

Add SYNC_USER2, SYNC_USER3, … for additional users.

Docker

docker run \
  -e SYNC_USER1=alice@example.com:secret \
  -p 8080:8080 \
  anki-cloud-sync:local

Environment variables

Variable Default Description
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_USER1 username:password — repeat for SYNC_USER2, SYNC_USER3, …
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)
SYNC_INTERNAL_TOKEN Bearer token required on every internal API request; if unset, internal API is disabled

Authentication

On /sync/hostKey (Anki login):

  1. Derives hkey = SHA1(username:password)
  2. Looks up user in in-memory map (populated from SYNC_USER* at startup)
  3. Verifies password against stored PBKDF2 hash
  4. Returns hkey to Anki client as session token

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

Platform implementations

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:

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<()>;
}

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.

The anki-cloud repo contains the reference cloud platform implementation (sync-platform-cloud). See ADR-0014 for the rationale.

Internal API

When SYNC_INTERNAL_TOKEN is set, the server exposes a second HTTP listener on SYNC_INTERNAL_PORT (default 8081). This API lets the rest of the platform (e.g. the anki-cloud REST API) read and write deck/note data without going through the Anki sync protocol.

Every request must carry two headers:

Header Value
X-Internal-Token value of SYNC_INTERNAL_TOKEN
X-User-Email email address of the user to act as

Endpoints

Method Path Description
GET /internal/v1/decks List decks (paginated)
POST /internal/v1/decks Create a deck
GET /internal/v1/decks/{id} Get a deck by ID
DELETE /internal/v1/decks/{id} Delete a deck
GET /internal/v1/decks/{id}/notes List notes in a deck (paginated)
POST /internal/v1/decks/{id}/notes Create a single note
POST /internal/v1/decks/{id}/notes/bulk Create multiple notes in one request
GET /internal/v1/notes/search Search notes by Anki query (paginated)
GET /internal/v1/notes/{id} Get a note by ID
PUT /internal/v1/notes/{id} Update a note
DELETE /internal/v1/notes/{id} Delete a note

Data freshness

The internal API reads from a local .anki2 file at SYNC_BASE/<email>/collection.anki2. This file is downloaded from cloud storage when an Anki client opens a sync session (open_collectionbackend.fetch()), and uploaded back when the session closes (backend.commit()). Between syncs the local file is not refreshed.

Consequences:

  • Reads reflect the last Anki client sync, not the live state in cloud storage. If no sync has happened yet for a user, the file may be absent or stale.
  • Writes (create/update/delete) are committed immediately to both the local file and cloud storage (with_col_and_commitbackend.commit()), so they are durable and will be picked up by the next Anki client sync.
  • Changing folder_path in storage_connections takes effect on the next Anki client sync. Until then, the internal API continues reading from the existing local file.

Pagination

List and search endpoints accept:

Query param Default Max Description
limit 100 1000 Number of items to return
cursor Opaque string from previous nextCursor

Responses include "nextCursor": "<string>" (or null when no more pages).

Example

# List decks (first page)
curl -s "http://localhost:8081/internal/v1/decks?limit=10" \
  -H "X-Internal-Token: $SYNC_INTERNAL_TOKEN" \
  -H "X-User-Email: alice@example.com"

# Bulk-create notes in deck 1234567890
curl -s -X POST "http://localhost:8081/internal/v1/decks/1234567890/notes/bulk" \
  -H "X-Internal-Token: $SYNC_INTERNAL_TOKEN" \
  -H "X-User-Email: alice@example.com" \
  -H "Content-Type: application/json" \
  -d '{"notes":[{"fields":{"Front":"Q","Back":"A"},"tags":[]}]}'

Test

cargo test -p sync-platform-api
cargo test -p sync-storage-backends
cargo test -p sync-storage-server

Versioning

Crate versions

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.0925.9.0, Anki 25.09.225.9.2. Leading zeros are dropped (Cargo strips them anyway). The -rX revision counter is not baked into the crate version to avoid collisions with upstream patch releases. When upgrading rslib, bump all four crate versions to match the new upstream version.

Git tags

Tags follow v<anki-version>-r<revision> (e.g. v25.09-r1).

The Anki version prefix signals sync protocol compatibility. -rX is our revision counter for changes layered on top of that upstream base — it resets to -r1 whenever the upstream Anki version changes. This avoids collisions with Anki's own patch versions (25.09, 25.09.1, 25.09.2, …).

Releases are automated. Merging a PR to main with at least one feat:, fix:, or perf: commit triggers .github/workflows/auto-tag.yml, which reads .version, finds the latest matching tag, increments the revision, and pushes the new tag. The release.yml workflow then fires on that tag to build and publish the Docker image and create a GitHub release. chore:, docs:, refactor:, and similar commit types do not trigger a release.

Docker image tag Anki client version
ghcr.io/danielpmichalski/anki-cloud-sync:v25.09-r1 25.09.x

Upgrading to a new Anki release

Step 1 — replace rslib

Run the fork script with the new upstream tag. It replaces rslib/, ftl/, proto/, and Cargo.lock while leaving Cargo.toml, our custom crates, and this file untouched.

./scripts/fork-anki-sync-server.zsh 25.12

Step 2 — re-apply the rslib delta

The fork script wipes our patches. Re-apply the changes listed in the rslib delta section above. The diff is small and intentionally stable — if upstream renames a type or restructures a method, that's the only thing that needs updating.

Quick checklist:

  • sync-storage-*/Cargo.toml — bump version to match new Anki version (e.g. 25.9.2)
  • rslib/Cargo.tomlsync-platform-api in [dependencies], anyhow in [dev-dependencies]
  • rslib/sync/Cargo.tomlsync-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
  • rslib/src/sync/http_server/user.rsbackend_resolver field, collapsed open_collection / with_col_and_commit
  • rslib/src/sync/http_server/handlers.rs — collapsed finish / upload commit blocks
  • rslib/src/sync/collection/tests.rs — inline test doubles, updated make_server call
  • rslib/src/sync/http_server/internal_handlers.rsdelete (lives in sync-storage-server)
  • rslib/src/sync/http_server/internal_server.rsdelete (lives in sync-storage-server)

Step 3 — verify structure

# zero-tolerance checks — all must return empty
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

cargo build --bin anki-sync-server

Step 4 — run automated tests

# sync protocol integration tests (uses StandaloneAuthProvider + LocalBackendResolver)
cargo test -p anki

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

All tests must pass before tagging.

Step 5 — merge and let automation tag

Use a feat: commit type for the upgrade PR (e.g. feat: upgrade to anki 25.12). When the PR merges to main, the auto-tag workflow reads the updated .version file, finds no existing tags for the new Anki version, and creates v25.12-r1 automatically.

About

Stateless Anki sync server backed by user-owned cloud storage (Google Drive, Dropbox, S3). Fork of ankitects/anki rslib, AGPLv3.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages