Stateless Anki sync server backed by user-owned cloud storage (Google Drive, Dropbox, S3).
Fork of ankitects/anki@25.09 rslib, AGPLv3.
/
├── 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
| 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/ 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.
# in [dependencies]
sync-platform-api.workspace = true
# also add anyhow to [dev-dependencies]# in [target.'cfg(...)'.dependencies] (both windows and non-windows blocks)
sync-storage-server = { workspace = true }println!("{}", sync_storage_server::run());SimpleServerstruct: replacemode: SyncModewithauth: Arc<dyn AuthProvider>andbackend_resolver: Arc<dyn BackendResolver>(both fromsync_platform_api)SyncServerConfig: removemode: SyncModefield (SYNC_MODE is read insync-storage-server)SimpleServer::new(): takesauth+backend_resolverinstead ofmodeSimpleServer::make_server(): takes pre-builtArc<SimpleServer>as second argument; no sidecar spawn (that moves tosync-storage-server)with_authenticated_user(): single code path — callsself.auth.lookup_by_hkey()get_host_key(): single code path — callsself.auth.authenticate()ensure_user(): takesbackend_resolver: Arc<dyn BackendResolver>instead ofmodeget_or_create_sidecar_user(): same — takesbackend_resolverinstead ofmode- Add
SidecarUserHandle<'a>struct (opaque handle exposingwith_col/with_col_and_commit) - Add
SimpleServer::with_sidecar_user()pub method (used by sidecar handlers insync-storage-server) - Add
pub fn derive_hkey(s: &str) -> String(used byStandaloneAuthProvider) - Add
pub fn base_folder(&self) -> &Pathaccessor - Remove
SimpleServer::run()(replaced bysync_storage_server::run()) - Remove
mod internal_handlersandmod internal_serverdeclarations
Userstruct: replacemode: SyncModewithbackend_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()
finish(): replace 20-line duplicated match block with two lines:resolve_for_user()+backend.commit()upload(): same
- Replace
mode: SyncMode::StandaloneinSyncServerConfigliteral with inlineTestAuthProviderandLocalBackendResolvertest doubles - Update
make_server(config)call tomake_server(config, server)(pass pre-builtArc<SimpleServer>)
- Rust stable ≥ 1.80 (
rustup update stable) - Protocol Buffers compiler:
brew install protobuf(macOS) orapt install protobuf-compiler(Linux)
cargo build --bin anki-sync-serverThe binary lands at target/debug/anki-sync-server.
docker build -t anki-cloud-sync:local .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.
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 run \
-e SYNC_USER1=alice@example.com:secret \
-p 8080:8080 \
anki-cloud-sync:local| 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 |
On /sync/hostKey (Anki login):
- Derives
hkey = SHA1(username:password) - Looks up user in in-memory map (populated from
SYNC_USER*at startup) - Verifies password against stored PBKDF2 hash
- Returns
hkeyto Anki client as session token
On subsequent sync requests: looks up hkey in in-memory session map.
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.
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 |
| 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 |
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_collection → backend.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_commit→backend.commit()), so they are durable and will be picked up by the next Anki client sync. - Changing
folder_pathinstorage_connectionstakes effect on the next Anki client sync. Until then, the internal API continues reading from the existing local file.
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).
# 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":[]}]}'cargo test -p sync-platform-api
cargo test -p sync-storage-backends
cargo test -p sync-storage-serverCustom 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
with upstream patch releases. When upgrading rslib, bump all four crate versions to match the new
upstream version.
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 |
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.12The 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— bumpversionto match new Anki version (e.g.25.9.2) -
rslib/Cargo.toml—sync-platform-apiin[dependencies],anyhowin[dev-dependencies] -
rslib/sync/Cargo.toml—sync-storage-serverin both platform dependency blocks -
rslib/sync/main.rs— callssync_storage_server::run()notSimpleServer::run() -
rslib/src/sync/http_server/mod.rs— DI fields, simplified auth methods,SidecarUserHandle,derive_hkey,base_folder(), updatednew()/make_server()signatures -
rslib/src/sync/http_server/user.rs—backend_resolverfield, collapsedopen_collection/with_col_and_commit -
rslib/src/sync/http_server/handlers.rs— collapsedfinish/uploadcommit blocks -
rslib/src/sync/collection/tests.rs— inline test doubles, updatedmake_servercall -
rslib/src/sync/http_server/internal_handlers.rs— delete (lives insync-storage-server) -
rslib/src/sync/http_server/internal_server.rs— delete (lives insync-storage-server)
# 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# 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-serverAll tests must pass before tagging.
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.