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 formatif (!isValidFileId(fileId)) {return c.json({error: "Invalid file ID format"}, 400);}// Connect to database to verify file exists and is uploadedconst 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 activeif (!fileRecord.active) {return c.json({error: "File is not active"}, 404);}// Check if file is uploaded to B2if (!fileRecord.upload) {return c.json({error: "File is not available for download"}, 403);}// Authenticate with B2const authData = await authenticateB2(c.env);// Construct B2 filename: fileId.flacconst b2FileName = `${fileId}.flac`;// Download file from B2const fileResponse = await downloadB2File(b2FileName,authData,c.env.B2_BUCKET_NAME);// Get the file contentconst fileContent = await fileResponse.arrayBuffer();// Return the file with proper headers for audio streamingreturn 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 typesif (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 RequiredYou 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_hereB2_APPLICATION_KEY=your_application_key_hereB2_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 secretswrangler secret put B2_APPLICATION_KEY_IDwrangler secret put B2_APPLICATION_KEY# Set non-sensitive config as environment variablewrangler 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:```bashwrangler secret put B2_APPLICATION_KEY_IDwrangler secret put B2_APPLICATION_KEY```Or add them in the Cloudflare Dashboard:1. Go to Workers & Pages2. Select your worker3. Go to Settings → Environment Variables4. Add the three variables above## B2 Application Key PermissionsWhen creating your B2 Application Key, ensure it has:- **Read Files** permission for the `skraak` bucket- **List Files** permission for the `skraak` bucketYou don't need write permissions for this download-only functionality.## Testing the SetupOnce configured, you can test the download endpoint:```bash# Get a valid file ID from your database where upload=truecurl -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.