Dragonyx is a Google Drive-backed cloud-storage bridge — a thin wrapper that turns a Google account and Drive folder into a file store, exposed through a clean HTTP API and an embeddable Go library. Upload a file and receive a portable, self-describing
dragonyx://reference to persist in your own database; resolve it later to stream the file back. Large files are transparently split into chunks on upload and reassembled in parallel on download. Stateless, dependency-free, and with optional API-key authentication, it is built to drop into SaaS backends as a lightweight storage layer.
- Stdlib only — zero third-party dependencies, just Go +
net/http(including the OAuth2 flow). - Stateless bridge — Dragonyx does not store anything. You save the link in your own database.
- Chunked uploads — files larger than the configurable chunk size (
ChunkSize, default 256 MB, max 5 GB) are split into multiple Drive files and reassembled in parallel on download. - One binary, two modes —
importas a Go library, or run as an HTTP service on:9009. - Tiny Docker image — multi-stage build, distroless base, ~8 MB.
- Resilient — built-in rate-limit handling, exponential backoff with jitter, context cancellation, per-chunk retry, multi-account failover.
Unlike messaging platforms, Google Drive is an actual storage product with an official API — using it this way is entirely within its intended purpose. Mind the quotas, though: a free account has 15 GB of storage, uploads are capped around 750 GB/day per account, and API requests are throttled per project/user. The Scaling section shows how to spread load across accounts.
- In Google Cloud Console, create (or pick) a project.
- APIs & Services → Library → enable the Google Drive API.
- APIs & Services → OAuth consent screen → configure it (External is fine). Then set Publishing status to In production — refresh tokens issued by apps left in Testing mode expire after 7 days.
- APIs & Services → Credentials → Create Credentials → OAuth client ID → application type Desktop app. Copy the client id and client secret.
Dragonyx defaults to the
drive.filescope: it can only see files and folders it created itself. That scope is non-sensitive (no Google verification needed) and keeps the rest of your Drive invisible to the bridge.
export DRAGONYX_CLIENT_ID='1234-abc.apps.googleusercontent.com'
export DRAGONYX_CLIENT_SECRET='GOCSPX-...'
dragonyx auth
# open the printed URL in a browser, approve, copy the printed env valuesdragonyx mkdir Dragonyx # prints the folder id
export DRAGONYX_FOLDER_ID=<printed id>With the default drive.file scope the target folder must be created by Dragonyx (that is what mkdir is for). To reuse an existing folder instead, run dragonyx auth with DRAGONYX_OAUTH_SCOPE=https://www.googleapis.com/auth/drive.
cp .env.example .env
# edit .envAs a Go binary:
make build
./bin/dragonyx serveAs a Docker container:
docker compose up -dAs a Go library:
import "github.com/phalconyx/dragonyx"
client, _ := dragonyx.NewClient(dragonyx.Config{
ClientID: "1234-abc.apps.googleusercontent.com",
ClientSecret: "GOCSPX-...",
RefreshToken: "1//0...",
FolderID: "1AbCdEf...", // optional; "" = My Drive root
MaxUploadSize: 10 * 1024 * 1024 * 1024, // 10 GB (default)
MaxDownloadSize: 10 * 1024 * 1024 * 1024,
ChunkSize: 256 * 1024 * 1024, // default
ChunkConcurrency: 3, // default
})
// Upload — files > ChunkSize are auto-chunked.
result, _ := client.UploadFile(ctx, "big-backup.tar.gz")
fmt.Println(result.Link()) // dragonyx://file/...
if result.ChunkCount > 1 {
fmt.Printf("split into %d chunks\n", result.ChunkCount)
}
// Save result.Link() anywhere in your own storage.
// Later: download — chunks are reassembled in parallel.
link, _ := dragonyx.ParseURL(result.Link())
client.Download(ctx, link, "big-backup.tar.gz")dragonyx serve Run HTTP server (default :9009)
dragonyx upload <file> Upload a file, print the dragonyx:// link to stdout
dragonyx download <url> <dest> Download a file by dragonyx:// URL
dragonyx delete <url> Delete the Drive file(s) behind a dragonyx:// URL
dragonyx mkdir <name> [parent-id] Create a Drive folder, print its id
dragonyx auth Run the OAuth flow, print a refresh token
dragonyx healthcheck Probe the local server's /health (used by the Docker healthcheck)
dragonyx version Print version
dragonyx help Show usage
Environment variables:
| Variable | Required | Default | Description |
|---|---|---|---|
DRAGONYX_CLIENT_ID |
yes* | — | OAuth2 client id (*or use DRAGONYX_ROUTES) |
DRAGONYX_CLIENT_SECRET |
yes* | — | OAuth2 client secret (*or use DRAGONYX_ROUTES) |
DRAGONYX_REFRESH_TOKEN |
yes* | — | OAuth2 refresh token from dragonyx auth (*or use DRAGONYX_ROUTES) |
DRAGONYX_FOLDER_ID |
no | — | Target Drive folder id; empty = My Drive root |
DRAGONYX_ROUTES |
no | — | Multiple account routes: alias=client_id:client_secret:refresh_token@folder_id,... (see Scaling) |
DRAGONYX_DEFAULT_ROUTE |
no | first route | Route alias assumed for links without a route marker |
DRAGONYX_API_BASE |
no | https://www.googleapis.com |
Google APIs server root (tests/proxies) |
DRAGONYX_TOKEN_URL |
no | https://oauth2.googleapis.com/token |
OAuth2 token endpoint |
DRAGONYX_API_KEY |
no | empty | API key for HTTP server auth |
DRAGONYX_LISTEN |
no | :9009 |
Server listen address |
DRAGONYX_TIMEOUT |
no | 60s |
HTTP connection/response-header timeout (body transfer is never time-limited) |
DRAGONYX_MAX_UPLOAD_SIZE |
no | 10GB |
Max total file size for upload (e.g. 500MB, 10GB) |
DRAGONYX_MAX_DOWNLOAD_SIZE |
no | 10GB |
Max total file size for download |
DRAGONYX_CHUNK_SIZE |
no | 256MB |
Chunk size for split uploads (max 5GB) |
DRAGONYX_CHUNK_CONCURRENCY |
no | 3 |
Number of concurrent chunk downloads |
DRAGONYX_OAUTH_SCOPE |
no | .../auth/drive.file |
Scope used by dragonyx auth |
Size suffixes are all binary (powers of 1024): B, K/KB, M/MB, G/GB, T/TB all use 1024. So 256MB = 256 × 1024 × 1024 bytes. This matches the on-disk byte count of files. For an exact decimal byte count, pass a bare number (e.g. 256000000).
All JSON responses share a consistent envelope. The HTTP status code is authoritative — the body never contradicts it.
Success (2xx):
{ "data": { ... }, "meta": { "request_id": "req_8f2a1c..." } }Error (4xx/5xx):
{ "error": { "code": "invalid_link", "message": "human-readable detail" }, "meta": { "request_id": "req_8f2a1c..." } }error.code is a stable, machine-readable identifier (unauthorized, invalid_json, missing_url, invalid_link, unknown_route, missing_file, invalid_multipart, upload_failed, upload_too_large, download_failed, delete_failed, internal); error.message is for humans. Every response also carries an X-Request-Id header echoing meta.request_id — send your own X-Request-Id to propagate a trace id. The only non-JSON response is a successful /download, which streams raw file bytes.
GET /health
{ "data": { "status": "ok", "time": "2026-07-22T10:30:00Z" }, "meta": { "request_id": "req_8f2a1c..." } }POST /upload (multipart/form-data)
curl -X POST http://localhost:9009/upload \
-H "X-API-Key: $DRAGONYX_API_KEY" \
-F "file=@report.pdf"Response 201 Created:
{
"data": {
"url": "dragonyx://file/eyJmIjoiMUFiQ2QtLS0ifQ==",
"file_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"size": 1048576,
"name": "report.pdf",
"mime_type": "application/pdf",
"md5": "9e107d9d372bb6826bd81d3542a419d6"
},
"meta": { "request_id": "req_8f2a1c..." }
}In multi-route mode (DRAGONYX_ROUTES), data also includes route — the alias of the route that stored the file. For chunked uploads, data also includes chunk_size, chunk_count, and a chunks array (and md5 is omitted — Drive checksums each chunk file, not the whole):
{
"data": {
"...": "...",
"chunk_size": 268435456,
"chunk_count": 4,
"chunks": [
{"index": 0, "file_id": "1AbC...", "size": 268435456},
{"index": 1, "file_id": "1DeF...", "size": 268435456}
]
},
"meta": { "request_id": "req_8f2a1c..." }
}POST /download (application/json)
# -OJ saves the file under its ORIGINAL name + extension, taken from the
# response's Content-Disposition header. Use -o myname.ext instead to pick
# the local name yourself.
curl -X POST http://localhost:9009/download \
-H "X-API-Key: $DRAGONYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"dragonyx://file/eyJmIjoiLi4uIn0="}' \
-OJResponse: on success, the raw file bytes (not enveloped), with Content-Type, Content-Disposition: attachment; filename="..." and (for chunked files) X-Dragonyx-Chunks: N headers when known. The real filename and type come from these headers — the output filename you pass to curl (-o) is just a local choice and does not have to match. Errors that occur before the first body byte (bad request, unknown link, deleted Drive file, revoked token, Google unreachable) use the standard JSON error envelope (download_failed); a failure after streaming has begun can only abort the connection, which the client sees as a truncated body.
POST /delete (application/json)
curl -X POST http://localhost:9009/delete \
-H "X-API-Key: $DRAGONYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"dragonyx://file/eyJmIjoiLi4uIn0="}'Response 200 OK:
{
"data": { "deleted_files": 3, "total_chunks": 3 },
"meta": { "request_id": "req_8f2a1c..." }
}Deletes the Drive file(s) backing the link. For chunked files every part is removed. Notes:
- Deletion is permanent —
files.deletebypasses the Drive trash. - Deletion is idempotent — parts that are already gone (404) count as deleted, so replaying a delete is safe.
- On failure some parts may already have been deleted (deletion is attempted for every part; the first error is returned).
Google Drive happily stores single files up to 5 TB, so unlike message-based backends Dragonyx does not need to split files. Chunking still pays for itself on large transfers, which is why files above ChunkSize (default 256 MB) are split anyway:
- Retry granularity — an upload or download attempt that dies mid-stream repeats one chunk, not the whole file.
- Parallel reassembly — the library uses up to
ChunkConcurrencyworkers (default 3) to fetch chunks in parallel viaWriteAt, so downloads of big files are fast. - Chunks are pre-allocated as a single file via
Truncateto avoid sparse-file issues.
Raise ChunkSize (up to 5 GB) to store fewer Drive files per upload, or set it above your file sizes to effectively disable splitting. The dragonyx:// link contains all chunk references, so a single URL is enough to reassemble the whole file. The URL is only slightly longer for chunked files (~45 bytes per extra chunk).
If a chunked upload fails partway through, the chunks that did succeed are already in your folder. To prevent duplicates on retry, Dragonyx classifies permanent failures as *NonRetryableError and stops retrying immediately. Transient failures (5xx, network) are still retried.
You can clean up the partial files with DeleteChunks:
link, _ := dragonyx.ParseURL(savedURL) // URL of the partial upload
if err := client.DeleteChunks(ctx, link); err != nil {
log.Printf("some chunks could not be deleted: %v", err)
}DeleteChunks deletes every Drive file referenced in the link (already-deleted parts are fine). After cleanup, retry the upload. The same operation is exposed over HTTP as POST /delete (see HTTP API).
Google Drive quotas apply per account: 15 GB storage on a free account, ~750 GB of uploads per day, and per-user API rate limits. To spread load — or to pool the free storage of several accounts — configure multiple routes, each an (alias, credentials, folder) triple:
DRAGONYX_ROUTES='a1=1111-x.apps.googleusercontent.com:GOCSPX-aaa:1//0aaa@folderA,a2=2222-y.apps.googleusercontent.com:GOCSPX-bbb:1//0bbb@folderB'
# The folder id may be empty for My Drive root: "a3=...:...:...@"Two routes may share an OAuth client with different accounts' refresh tokens, or use separate Google Cloud projects entirely (raises the per-project API ceiling).
How routing works. Each upload goes to the route with the fewest uploads currently in flight (least-inflight); ties rotate round-robin, so an idle pool cycles evenly. Routes in a rate-limit cooldown are skipped automatically, and an upload fails over to the next route when a route errors before anything was stored — including when a route's account is out of storage (storageQuotaExceeded) or its token was revoked. A Drive file id is only accessible with the credentials that stored it, so each dragonyx:// link records its route alias, and downloads/deletes are always routed back to the origin account. All chunks of one file go through a single route.
Aliases are permanent. The alias is stored inside every link created through it (Dragonyx itself stays stateless — no database). Renaming or removing an alias breaks the links that reference it; the server then returns 400 unknown_route. Adding new routes is always safe.
Migrating from a single account. Links created before routing carry no alias; a multi-route server resolves them to DRAGONYX_DEFAULT_ROUTE (default: the first route). Keep your original account as that route and all old links keep working — single-account setups also still emit byte-identical links, so nothing changes until you opt in.
As a library, the same is available via dragonyx.NewPool:
pool, _ := dragonyx.NewPool(dragonyx.PoolConfig{
Routes: []dragonyx.Route{
{Alias: "a1", ClientID: "...", ClientSecret: "...", RefreshToken: "...", FolderID: "..."},
{Alias: "a2", ClientID: "...", ClientSecret: "...", RefreshToken: "...", FolderID: "..."},
},
// Picker: custom strategy; default is least-inflight with round-robin
// tie-break, plus rate-limit cooldown. NewRoundRobin() forces plain rotation.
})
result, _ := pool.UploadFile(ctx, "big-backup.tar.gz") // picks a route
link, _ := dragonyx.ParseURL(result.Link())
pool.Download(ctx, link, "big-backup.tar.gz") // routed to the origin accountPool exposes the same operations as Client, so server.New accepts either.
| Limit | Default | Configurable |
|---|---|---|
| Per-file Drive limit | 5 TB | — (Google's cap) |
| Chunk size | 256 MB | ChunkSize (capped at 5 GB) |
| Total file size for upload | 10 GB | MaxUploadSize |
| Total file size for download | 10 GB | MaxDownloadSize |
In-memory upload (UploadReader) |
128 MB | — (use UploadFile for larger) |
| Concurrent chunk downloads | 3 | ChunkConcurrency |
| Free storage per account | 15 GB | more accounts (Scaling) or a paid plan |
| Upload volume per account | ~750 GB/day | more accounts |
A quick way to validate your setup end to end: upload a file and download it back (dragonyx upload / dragonyx download), then check the folder in the Drive web UI.
dragonyx/
├── client.go Client, Config, retry, defaults
├── pool.go Pool, Route, Picker (least-inflight + rate-limit cooldown)
├── upload.go UploadFile (chunked), UploadReader, delete, Drive error mapping
├── download.go Download (parallel), DownloadTo
├── folder.go CreateFolder (for the drive.file scope)
├── link.go FileLink, ChunkRef, dragonyx:// codec
├── errors.go APIError, RateLimitError, PartialUploadError, ...
├── client_test.go
├── internal/transport/ raw HTTP (OAuth2 token refresh, resumable upload, streaming)
├── server/ net/http handlers (port 9009)
├── cmd/dragonyx/ CLI entry point (serve, upload, download, auth, mkdir)
├── examples/basic/ library usage example
├── Dockerfile multi-stage, distroless
├── docker-compose.yml
├── Makefile
├── .env.example
└── go.mod zero third-party deps
make tidy # go mod tidy
make build # build binary
make test # go test -v -race ./...
make lint # go vet ./...The test suite is fully hermetic: it runs against an in-memory fake of the Drive API (fakedrive_test.go) and never touches the network.
MIT