A lightweight desktop app for uploading files to Backblaze B2 via the S3-compatible API. Runs on macOS, Windows, and Linux. Built with Rust, Tauri 2, and vanilla JS.
Drop files onto the window, get shareable URLs back instantly.
- Drag-and-drop uploads - drop one or many files onto the window
- Concurrent uploads - up to 5 files upload simultaneously with per-file status
- Two folder modes - toggle between two independently configured folders (e.g. "private" and "shared")
- Auto-copy - single-file uploads are automatically copied to the clipboard
- Upload history - browse and copy URLs from previous uploads
- Cancel uploads - cancel in-progress batch uploads; in-flight files finish, remaining are skipped
- Individual history deletion - remove single entries from upload history
- Settings validation - required fields are validated before saving with visual feedback
- Encrypted credential storage - sensitive keys stored individually in the system keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service) with automatic memory zeroization; non-sensitive config stored in a local JSON file
- Configurable upload paths - date folders, UUID filenames, overwrite protection, and per-folder URL tokens are all optional
- URL encoding - filenames with spaces and special characters are properly percent-encoded
Open settings with the gear icon. There are three sections:
| Field | Description |
|---|---|
| Domain | Your public-facing domain (e.g. media.example.com) |
| Bucket Name | The B2 bucket name |
| S3 Endpoint | S3-compatible endpoint (e.g. s3.us-east-005.backblazeb2.com) |
| Application Key ID | B2 app key ID |
| Application Key | B2 app key secret |
All five connection fields are required before uploads will work.
Two folders can be configured. Each has a name and an optional URL token.
| Field | Default | Description |
|---|---|---|
| Folder 1 | private |
Name used as the top-level prefix in the object key. Leave blank to upload to the bucket root. |
| Folder 1 Token | (empty) | If set, appended as ?token=xxx to the returned URL. If blank, no token is added. |
| Folder 2 | shared |
Same as above for the second folder. |
| Folder 2 Token | (empty) | Same as above. |
The toggle on the main screen switches between Folder 1 and Folder 2. The toggle labels update to match whatever names you've configured (capitalized).
| Setting | Default | Description |
|---|---|---|
| Dynamic tokens | Off | When on, generates HMAC-SHA256 signed URLs with expiration instead of static per-folder tokens |
| Token Secret | (empty) | Shared HMAC-SHA256 secret (must match the secret configured in your Cloudflare Worker) |
| Default TTL | 1 hour | Default time-to-live for signed URLs. A TTL dropdown also appears on the main screen when dynamic mode is enabled. |
When dynamic mode is enabled, static token fields are hidden (values are preserved). The generated URL format is https://domain/key?token=SIGNATURE&expires=TIMESTAMP where the signature is HMAC-SHA256 over /{object_key}:{expires}, base64url-encoded without padding.
| Option | Default | Description |
|---|---|---|
| Date folders | On | Inserts a YYYY/MM/DD path segment after the folder name |
| UUID filenames | On | Replaces the original filename with a random UUID. Prevents filename collisions. |
| Overwrite uploads | Off | When off and UUID filenames are also off, the app checks if the file already exists before uploading and returns an error if it does. When UUID filenames are on, this check is skipped (no collisions possible). |
With all defaults and Folder 1 selected:
private/2026/02/20/a3f7c21e-1234-5678-abcd-ef0123456789.png
Date folders off:
private/a3f7c21e-1234-5678-abcd-ef0123456789.png
UUID filenames off:
private/2026/02/20/screenshot.png
Folder name blank, date off, UUID off:
screenshot.png
The folder tokens are designed for use with a proxy that sits between your users and Backblaze B2. Instead of exposing your B2 bucket directly, you point a custom domain at a Cloudflare Worker that checks the ?token= parameter before serving the file. This way you can share links that only work with the right token, and have different tokens for different folders.
- B2Upload appends
?token=abc123to the URL it generates - A user or browser requests
https://media.example.com/shared/photo.png?token=abc123 - Your Cloudflare Worker checks the token against the expected value for that folder path
- If valid, the worker strips the token and fetches the file from B2
- If invalid, the worker returns 401 Unauthorized
- Create a Cloudflare Worker on your account
- Set a custom route so it handles requests to your domain (e.g.
media.example.com/*) - Add your tokens as environment variables in the Worker settings (e.g.
SHARED_TOKEN,PRIVATE_TOKEN) - Deploy the worker code below
- In B2Upload settings, enter the same token values in the Folder 1/Folder 2 Token fields
export default {
async fetch(request, env) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
const path = url.pathname;
// Check token based on folder path
let authorized = false;
if (path.startsWith("/shared/")) {
authorized = token === env.SHARED_TOKEN;
} else if (path.startsWith("/private/")) {
authorized = token === env.PRIVATE_TOKEN;
}
if (!authorized) {
return new Response("Unauthorized", { status: 401 });
}
// Strip token before proxying to B2
url.searchParams.delete("token");
// Fetch from B2 - replace with your B2 download URL and bucket name
const b2Url = `https://f000.backblazeb2.com/file/your-bucket-name${path}`;
const response = await fetch(b2Url);
// Return with CORS headers
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", "*");
return new Response(response.body, {
status: response.status,
headers,
});
},
};Dynamic token + expiry example cloudflare_worker.js
const VERSION = "1.1.2";
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/version") {
return new Response(VERSION);
}
const token = url.searchParams.get("token");
const expires = url.searchParams.get("expires");
if (!token || !expires) {
return new Response("Unauthorized", { status: 401 });
}
// Check expiry first (expires is a Unix timestamp in seconds)
const now = Math.floor(Date.now() / 1000);
if (now > parseInt(expires, 10)) {
return new Response("Link expired", { status: 403 });
}
// Validate token - signed over path + expires together
const expectedToken = await generateToken(
path,
expires,
env.TOKEN_SECRET,
);
if (token !== expectedToken) {
return new Response("Unauthorized", { status: 401 });
}
// Strip params before proxying to B2
url.searchParams.delete("token");
url.searchParams.delete("expires");
const b2Url = `${env.B2_ORIGIN_URL}${path}`;
const response = await fetch(b2Url);
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", "*");
return new Response(response.body, {
status: response.status,
headers,
});
},
};
async function generateToken(path, expires, secret) {
const message = `${path}:${expires}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(message),
);
const base64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}Set the secret in Cloudflare as an environment variable called TOKEN_SECRET.
You can extend this to support a master token that works across all paths, multiple tokens per folder, or any other access pattern you need.
You can setup the variables and deploy with wrangler
# set secret(s), this will prompt you for the secret
wrangler secret put TOKEN_SECRET --name <worker-name>
# generate secret (make sure to save this somewhere lol)
SECRET=$(openssl rand -base64 32)
# set secret(s)
echo "$SECRET" | wrangler secret put TOKEN_SECRET --name media-auth
# deploy workers from wrangler.toml
wrangler deploy- Backend: Rust + Tauri 2
- Frontend: Vanilla JS + CSS (no build step)
- Storage: AWS S3 SDK (Backblaze B2 S3-compatible API)
- Credentials: Split storage -- non-sensitive config in
config.json, sensitive keys individually in system keyring viakeyringcrate withzeroizefor automatic memory clearing - Async: Tokio
Pre-built binaries are available from the Releases page for all three platforms.
| Platform | Formats |
|---|---|
| macOS | .dmg |
| Windows | .msi, .exe (NSIS) |
| Linux | .deb, .AppImage |
The app is not currently signed with an Apple Developer certificate. You may need to remove the quarantine attribute after installing:
xattr -cr /Applications/B2Upload.appRun the .msi installer or the .exe (NSIS) setup. No additional steps required.
Install the .deb package or run the .AppImage directly:
# Debian/Ubuntu
sudo dpkg -i b2upload_*.deb
# AppImage
chmod +x B2Upload_*.AppImage
./B2Upload_*.AppImageRequires Rust and the Tauri CLI.
cargo install tauri-cli
cargo tauri dev # run in development
cargo tauri build # build for current platformCI/CD builds for all three platforms with targets: dmg, deb, appimage, msi, nsis.
src/
index.html # Single-page UI with all views
app.js # Frontend logic
style.css # Dark theme styles
src-tauri/
src/
main.rs # Tauri commands and app setup
storage.rs # Split-tier storage (config.json + keyring), B2Credentials with zeroize, history with mutex
uploader.rs # S3 upload logic, path construction, percent-encoding
tauri.conf.json # App configuration
Cargo.toml # Rust dependencies
This project is licensed under the MIT License. See the LICENSE file for details.