AUEY3QXCUPYXL354YRB7AQBEOYX3GN5XGE47KX7TE6XT2QFP7G6AC
/**
* Backblaze B2 Cloud Storage utility functions
*
* This module provides utilities for interacting with Backblaze B2 storage
* including authentication and file download operations.
*/
import type { Env } from "../types";
/**
* B2 API response for authorization
*/
interface B2AuthResponse {
authorizationToken: string;
apiUrl: string;
downloadUrl: string;
recommendedPartSize: number;
accountId: string;
}
/**
* B2 file info response
*/
interface B2FileInfo {
fileId: string;
fileName: string;
accountId: string;
bucketId: string;
contentLength: number;
contentType: string;
fileInfo: Record<string, string>;
uploadTimestamp: number;
}
/**
* B2 API error response
*/
interface B2ErrorResponse {
status: number;
code: string;
message: string;
}
/**
* Authenticate with Backblaze B2 API
*
* @param env - Environment variables containing B2 credentials
* @returns Promise resolving to B2 authorization data
* @throws Error if authentication fails
*/
export async function authenticateB2(env: Env): Promise<B2AuthResponse> {
const credentials = btoa(`${env.B2_APPLICATION_KEY_ID}:${env.B2_APPLICATION_KEY}`);
const response = await fetch('https://api.backblazeb2.com/b2api/v2/b2_authorize_account', {
method: 'GET',
headers: {
'Authorization': `Basic ${credentials}`,
},
});
if (!response.ok) {
const errorData = await response.json() as B2ErrorResponse;
throw new Error(`B2 authentication failed: ${errorData.message} (${errorData.code})`);
}
return response.json() as Promise<B2AuthResponse>;
}
/**
* Get file info from B2
*
* @param fileName - Name of the file to get info for
* @param authData - B2 authorization data
* @param bucketName - Name of the B2 bucket
* @returns Promise resolving to file info or null if not found
*/
export async function getB2FileInfo(
fileName: string,
authData: B2AuthResponse,
bucketName: string
): Promise<B2FileInfo | null> {
const response = await fetch(`${authData.apiUrl}/b2api/v2/b2_list_file_names`, {
method: 'POST',
headers: {
'Authorization': authData.authorizationToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
bucketName,
startFileName: fileName,
maxFileCount: 1,
prefix: fileName,
}),
});
if (!response.ok) {
const errorData = await response.json() as B2ErrorResponse;
throw new Error(`Failed to get file info: ${errorData.message} (${errorData.code})`);
}
const data = await response.json() as { files: B2FileInfo[] };
const file = data.files.find(f => f.fileName === fileName);
return file || null;
}
/**
* Get download URL for a file in B2
*
* @param fileName - Name of the file to download
* @param authData - B2 authorization data
* @param bucketName - Name of the B2 bucket
* @returns Download URL for the file
*/
export function getB2DownloadUrl(
fileName: string,
authData: B2AuthResponse,
bucketName: string
): string {
return `${authData.downloadUrl}/file/${bucketName}/${fileName}`;
}
/**
* Download a file from B2 and return a Response object
*
* @param fileName - Name of the file to download
* @param authData - B2 authorization data
* @param bucketName - Name of the B2 bucket
* @returns Promise resolving to Response object with file data
* @throws Error if download fails
*/
export async function downloadB2File(
fileName: string,
authData: B2AuthResponse,
bucketName: string
): Promise<Response> {
const downloadUrl = getB2DownloadUrl(fileName, authData, bucketName);
const response = await fetch(downloadUrl, {
headers: {
'Authorization': authData.authorizationToken,
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`File not found: ${fileName}`);
}
let errorMessage = `Download failed with status ${response.status}`;
try {
const errorData = await response.json() as B2ErrorResponse;
errorMessage = `Download failed: ${errorData.message} (${errorData.code})`;
} catch {
// If we can't parse the error as JSON, use the generic message
}
throw new Error(errorMessage);
}
return response;
}
/**
* Validate that a file ID is properly formatted
*
* @param fileId - File ID to validate
* @returns true if valid, false otherwise
*/
export function isValidFileId(fileId: string): boolean {
// File IDs should be nanoid format (21 characters, URL-safe)
return /^[A-Za-z0-9_-]{21}$/.test(fileId);
}
/**
* Protected API route to download a file from Backblaze B2 storage
*
* @route GET /api/files/:fileId/download
* @authentication Required
* @param {string} fileId - File ID from the URL parameter (must be valid nanoid format)
* @returns {Response} Stream response with the FLAC audio file
* @error 400 - If fileId is invalid format
* @error 404 - If file is not found in database or not uploaded to B2
* @error 403 - If file is not marked as uploaded (upload=false)
* @error 500 - If B2 authentication or download fails
* @description Downloads a FLAC audio file from the private Backblaze B2 bucket.
* The file must exist in the database with upload=true status.
* The actual filename in B2 is constructed as: ${fileId}.flac
*
* Response includes proper headers for audio streaming:
* - Content-Type: audio/flac
* - Content-Disposition: inline (for browser playback)
* - Cache-Control: private, max-age=3600 (1 hour cache)
*/
files.get("/:fileId/download", authenticate, async (c) => {
try {
const fileId = c.req.param("fileId");
// Validate file ID format
if (!isValidFileId(fileId)) {
return c.json({
error: "Invalid file ID format"
}, 400);
}
// Connect to database to verify file exists and is uploaded
const db = createDatabase(c.env);
const fileResult = await db
.select({
id: file.id,
fileName: file.fileName,
upload: file.upload,
active: file.active
})
.from(file)
.where(eq(file.id, fileId))
.limit(1);
if (fileResult.length === 0) {
return c.json({
error: "File not found"
}, 404);
}
const fileRecord = fileResult[0];
// Check if file is active
if (!fileRecord.active) {
return c.json({
error: "File is not active"
}, 404);
}
// Check if file is uploaded to B2
if (!fileRecord.upload) {
return c.json({
error: "File is not available for download"
}, 403);
}
// Authenticate with B2
const authData = await authenticateB2(c.env);
// Construct B2 filename: fileId.flac
const b2FileName = `${fileId}.flac`;
// Download file from B2
const fileResponse = await downloadB2File(
b2FileName,
authData,
c.env.B2_BUCKET_NAME
);
// Get the file content
const fileContent = await fileResponse.arrayBuffer();
// Return the file with proper headers for audio streaming
return new Response(fileContent, {
status: 200,
headers: {
'Content-Type': 'audio/flac',
'Content-Disposition': `inline; filename="${fileRecord.fileName}"`,
'Content-Length': fileContent.byteLength.toString(),
'Cache-Control': 'private, max-age=3600', // 1 hour cache
'Accept-Ranges': 'bytes', // Enable range requests for audio seeking
},
});
} catch (error) {
console.error("Error downloading file:", error);
// Handle specific error types
if (error instanceof Error) {
if (error.message.includes('File not found')) {
return c.json({
error: "File not found in storage",
details: error.message
}, 404);
}
if (error.message.includes('authentication failed')) {
return c.json({
error: "Storage authentication failed",
details: error.message
}, 500);
}
}
return c.json({
error: "Failed to download file",
details: error instanceof Error ? error.message : String(error),
}, 500);
}
});
# Backblaze B2 Setup for Skraak
## Environment Variables Required
You need to add the following environment variables to your Cloudflare Workers environment:
### 1. B2_APPLICATION_KEY_ID
- This is your Backblaze B2 Application Key ID
- You can get this from your Backblaze B2 account dashboard
- Go to: Account → Application Keys → Create Application Key
### 2. B2_APPLICATION_KEY
- This is your Backblaze B2 Application Key (secret)
- Generated when you create an Application Key
- **Important**: This is sensitive - store securely
### 3. B2_BUCKET_NAME
- Set this to: `skraak`
- This should match your existing bucket name
## How to Add Environment Variables
### For Local Development (.dev.vars):
Create a `.dev.vars` file in your project root with:
```
B2_APPLICATION_KEY_ID=your_key_id_here
B2_APPLICATION_KEY=your_application_key_here
B2_BUCKET_NAME=skraak
```
**Note**: `.dev.vars` is the Cloudflare Workers equivalent of `.env` and is automatically used by `wrangler dev` and `npm run dev`. Make sure to add `.dev.vars` to your `.gitignore` file to keep secrets secure.
### For Production (Cloudflare Workers):
**Option 1: Using wrangler CLI (Recommended)**
```bash
# Set sensitive credentials as secrets
wrangler secret put B2_APPLICATION_KEY_ID
wrangler secret put B2_APPLICATION_KEY
# Set non-sensitive config as environment variable
wrangler env put B2_BUCKET_NAME skraak
```
**Option 2: Using wrangler.json for bucket name**
Add to your `wrangler.json`:
```json
{
"vars": {
"KINDE_ISSUER_URL": "https://skraak.kinde.com",
"B2_BUCKET_NAME": "skraak"
}
}
```
Then only set the sensitive credentials as secrets:
```bash
wrangler secret put B2_APPLICATION_KEY_ID
wrangler secret put B2_APPLICATION_KEY
```
Or add them in the Cloudflare Dashboard:
1. Go to Workers & Pages
2. Select your worker
3. Go to Settings → Environment Variables
4. Add the three variables above
## B2 Application Key Permissions
When creating your B2 Application Key, ensure it has:
- **Read Files** permission for the `skraak` bucket
- **List Files** permission for the `skraak` bucket
You don't need write permissions for this download-only functionality.
## Testing the Setup
Once configured, you can test the download endpoint:
```bash
# Get a valid file ID from your database where upload=true
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://skraak.app/api/files/YOUR_FILE_ID/download"
```
This should return the FLAC audio file if everything is configured correctly.