| ← Back to Documentation Index | Main README |
This guide shows how to integrate Garage S3-compatible storage into a Node.js/Express backend serving a React frontend.
Before integrating with Node.js, you need to set up access keys and permissions in Garage. This section covers the one-time setup required.
garage)Connect to your Garage node via SSH and create a key:
ssh mihay42@cafe-1 "docker exec garage /garage key create my-app-key"
Example Output:
==== ACCESS KEY INFORMATION ====
Key ID: GK88a87e7a792e4e30e54b9d45
Key name: my-app-key
Secret key: c7f3c0a6a1f4e8b2d9e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7
Created: "2025-11-17T10:30:00+00:00"
Valid until: (not set - valid forever)
Expires on: (not set - does not expire)
Can create buckets: false
Bucket permissions: (none)
Important: Save the Key ID and Secret key - you’ll need these for your .env file.
Allow the key to create buckets:
ssh mihay42@cafe-1 "docker exec garage /garage key allow --create-bucket GK88a87e7a792e4e30e54b9d45"
Replace GK88a87e7a792e4e30e54b9d45 with your actual Key ID from Step 1.
Example Output:
==== ACCESS KEY INFORMATION ====
Key ID: GK88a87e7a792e4e30e54b9d45
Key name: my-app-key
Secret key: (redacted)
Created: "2025-11-17T10:30:00+00:00"
Valid until: (not set - valid forever)
Expires on: (not set - does not expire)
Can create buckets: true
Bucket permissions: (none)
Note: Can create buckets is now true.
Create the buckets your application will use:
# Create main application bucket
ssh mihay42@cafe-1 "docker exec garage /garage bucket create app-uploads"
# Create user data bucket (if using multi-tenant approach)
ssh mihay42@cafe-1 "docker exec garage /garage bucket create user-data-2025-11"
Example Output:
Bucket app-uploads has been created
Grant read, write, and owner permissions to your key:
# Grant permissions using bucket name and key name
ssh mihay42@cafe-1 "docker exec garage /garage bucket allow \
--read \
--write \
--owner \
app-uploads \
--key my-app-key"
Example Output:
Bucket permissions updated successfully
Verify Permissions:
ssh mihay42@cafe-1 "docker exec garage /garage bucket info app-uploads"
Example Output:
Bucket: app-uploads
Size: 0 bytes (0 objects)
Website access: false
Global read access: false
Authorized keys:
- my-app-key (read, write, owner)
Add the credentials to your .env file:
# From Step 1
GARAGE_ACCESS_KEY_ID=GK88a87e7a792e4e30e54b9d45
GARAGE_SECRET_ACCESS_KEY=c7f3c0a6a1f4e8b2d9e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7
# Your Garage endpoint
GARAGE_ENDPOINT=http://cafe-1:3900
GARAGE_REGION=garage
For automated deployments, you can use the Garage Admin API instead of SSH:
# Create key via Admin API
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-X POST http://cafe-1:3903/v1/key \
-H "Content-Type: application/json" \
-d '{
"name": "my-app-key"
}'
# Grant bucket creation permission
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-X POST http://cafe-1:3903/v1/key/import \
-H "Content-Type: application/json" \
-d '{
"accessKeyId": "GK88a87e7a792e4e30e54b9d45",
"name": "my-app-key",
"allow": {
"createBucket": true
}
}'
# Create bucket
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-X POST http://cafe-1:3903/v1/bucket \
-H "Content-Type: application/json" \
-d '{
"globalAlias": "app-uploads"
}'
# Grant bucket permissions
curl -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-X POST http://cafe-1:3903/v1/bucket/allow \
-H "Content-Type: application/json" \
-d '{
"bucketId": "BUCKET_ID_FROM_PREVIOUS_RESPONSE",
"accessKeyId": "GK88a87e7a792e4e30e54b9d45",
"permissions": {
"read": true,
"write": true,
"owner": true
}
}'
Finding Admin Token:
ssh mihay42@cafe-1 "cat /opt/garage/garage.toml | grep admin_token"
| Task | Command |
|---|---|
| Create key | docker exec garage /garage key create <name> |
| Grant bucket creation | docker exec garage /garage key allow --create-bucket <key-id> |
| Create bucket | docker exec garage /garage bucket create <bucket-name> |
| Grant permissions | docker exec garage /garage bucket allow --read --write --owner <bucket> --key <key-name> |
| View key info | docker exec garage /garage key info <key-name> |
| View bucket info | docker exec garage /garage bucket info <bucket-name> |
| List buckets | docker exec garage /garage bucket list |
| List keys | docker exec garage /garage key list |
Note: Requires admin_token from garage.toml. Find it with:
ssh mihay42@cafe-1 "docker exec garage cat /etc/garage.toml | grep admin_token"
Setup Admin API Client:
// src/services/admin.service.ts
export class GarageAdminService {
private endpoint: string;
private token: string;
constructor() {
this.endpoint = process.env.GARAGE_ADMIN_ENDPOINT || 'http://cafe-1:3903';
this.token = process.env.GARAGE_ADMIN_TOKEN!;
}
private async request(path: string, options: RequestInit = {}) {
const response = await fetch(`${this.endpoint}${path}`, {
...options,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Admin API error: ${response.status} - ${error}`);
}
return response.json();
}
// Create a new access key
async createKey(name: string) {
const result = await this.request('/v2/CreateKey', {
method: 'POST',
body: JSON.stringify({ name }),
});
return {
accessKeyId: result.accessKeyId,
secretAccessKey: result.secretAccessKey,
name: result.name,
};
}
// Update key permissions (grant bucket creation)
async updateKeyPermissions(accessKeyId: string, permissions: {
createBucket?: boolean;
}) {
return await this.request(`/v2/UpdateKey?id=${encodeURIComponent(accessKeyId)}`, {
method: 'POST',
body: JSON.stringify({
allow: {
createBucket: permissions.createBucket,
},
}),
});
}
// Get key information
async getKey(search: string) {
return await this.request(`/v2/GetKeyInfo?search=${encodeURIComponent(search)}`);
}
// Get key by ID
async getKeyById(id: string) {
return await this.request(`/v2/GetKeyInfo?id=${encodeURIComponent(id)}`);
}
// List all keys
async listKeys() {
return await this.request('/v2/ListKeys');
}
// Create a bucket
async createBucket(name: string) {
const result = await this.request('/v2/CreateBucket', {
method: 'POST',
body: JSON.stringify({
globalAlias: name,
}),
});
return {
id: result.id,
globalAlias: result.globalAliases?.[0],
};
}
// Grant bucket permissions to a key
async allowBucketAccess(bucketId: string, accessKeyId: string, permissions: {
read?: boolean;
write?: boolean;
owner?: boolean;
}) {
return await this.request('/v2/AllowBucketKey', {
method: 'POST',
body: JSON.stringify({
bucketId,
accessKeyId,
permissions,
}),
});
}
// Get bucket info by global alias
async getBucket(globalAlias: string) {
return await this.request(`/v2/GetBucketInfo?globalAlias=${encodeURIComponent(globalAlias)}`);
}
// Get bucket info by ID
async getBucketById(id: string) {
return await this.request(`/v2/GetBucketInfo?id=${encodeURIComponent(id)}`);
}
// List all buckets
async listBuckets() {
return await this.request('/v2/ListBuckets');
}
// Get cluster status
async getClusterStatus() {
return await this.request('/v2/GetClusterStatus');
}
// Get cluster health
async getClusterHealth() {
return await this.request('/v2/GetClusterHealth');
}
// Get cluster layout
async getClusterLayout() {
return await this.request('/v2/GetClusterLayout');
}
}
Usage Examples:
Create Key and Grant Permissions (Full Automation):
import { GarageAdminService } from './services/admin.service';
async function provisionNewApplication(appName: string) {
const admin = new GarageAdminService();
// 1. Create a new access key
const key = await admin.createKey(`${appName}-key`);
console.log('Created key:', key.accessKeyId);
console.log('Secret:', key.secretAccessKey);
// 2. Grant bucket creation permission
await admin.updateKeyPermissions(key.accessKeyId, {
createBucket: true,
});
console.log('Granted bucket creation permission');
// 3. Create application bucket
const bucket = await admin.createBucket(`${appName}-data`);
console.log('Created bucket:', bucket.globalAlias);
// 4. Grant full permissions to the key
await admin.allowBucketAccess(bucket.id, key.accessKeyId, {
read: true,
write: true,
owner: true,
});
console.log('Granted bucket permissions');
return {
accessKeyId: key.accessKeyId,
secretAccessKey: key.secretAccessKey,
bucketName: bucket.globalAlias,
};
}
// Use it:
const credentials = await provisionNewApplication('my-app');
// Save credentials to your application's config
Create User-Specific Bucket:
async function provisionUserStorage(userId: string) {
const admin = new GarageAdminService();
// Create user bucket
const bucketName = `user-${userId}`;
const bucket = await admin.createBucket(bucketName);
// Grant permissions to existing application key
const appKeyId = process.env.GARAGE_ACCESS_KEY_ID!;
await admin.allowBucketAccess(bucket.id, appKeyId, {
read: true,
write: true,
owner: true,
});
return bucketName;
}
Check Cluster Health:
async function checkClusterHealth() {
const admin = new GarageAdminService();
const health = await admin.getClusterHealth();
return {
status: health.status, // 'healthy', 'degraded', or 'unavailable'
connectedNodes: health.connectedNodes,
totalNodes: health.knownNodes,
storageNodesUp: health.storageNodesUp,
totalStorageNodes: health.storageNodes,
};
}
Express Endpoint for Automated Provisioning:
// Admin endpoint to provision new tenant
app.post('/api/admin/provision-tenant', async (req, res) => {
try {
const { tenantId } = req.body;
const admin = new GarageAdminService();
// Create tenant-specific key
const key = await admin.createKey(`tenant-${tenantId}`);
// Create tenant bucket
const bucket = await admin.createBucket(`tenant-${tenantId}-data`);
// Grant permissions
await admin.allowBucketAccess(bucket.id, key.accessKeyId, {
read: true,
write: true,
owner: true,
});
// Store credentials in your database (encrypted!)
await db.tenants.update(tenantId, {
garageKeyId: key.accessKeyId,
garageSecret: encrypt(key.secretAccessKey),
garageBucket: bucket.globalAlias,
});
res.json({
success: true,
tenantId,
bucketName: bucket.globalAlias,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
Full Admin API Documentation: Garage Admin API Reference
Note: Requires AWS CLI configured with Garage credentials (see .aws/config and .aws/credentials setup above).
| Task | AWS CLI Command |
|---|---|
| Create bucket | aws s3 mb s3://my-bucket |
| List buckets | aws s3 ls |
| Upload file | aws s3 cp file.txt s3://my-bucket/ |
| Download file | aws s3 cp s3://my-bucket/file.txt ./ |
| List objects | aws s3 ls s3://my-bucket/ |
| Delete object | aws s3 rm s3://my-bucket/file.txt |
| Sync directory | aws s3 sync ./local-dir s3://my-bucket/remote-dir/ |
| Get object metadata | aws s3api head-object --bucket my-bucket --key file.txt |
Note: AWS CLI cannot create keys or manage bucket permissions - use Garage CLI or Admin API for those operations.
Create/Check Bucket:
import { S3Client, CreateBucketCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({
endpoint: 'http://cafe-1:3900',
region: 'garage',
credentials: {
accessKeyId: 'GK...',
secretAccessKey: '...',
},
forcePathStyle: true,
});
// Create bucket (requires key to have createBucket permission)
await s3.send(new CreateBucketCommand({ Bucket: 'my-bucket' }));
// Check if bucket exists
try {
await s3.send(new HeadBucketCommand({ Bucket: 'my-bucket' }));
console.log('Bucket exists');
} catch (error) {
console.log('Bucket does not exist');
}
Upload Object:
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { readFileSync } from 'fs';
const fileContent = readFileSync('./file.txt');
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
Body: fileContent,
ContentType: 'text/plain',
}));
Download Object:
import { GetObjectCommand } from '@aws-sdk/client-s3';
const response = await s3.send(new GetObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
}));
// Stream to file or buffer
const chunks = [];
for await (const chunk of response.Body) {
chunks.push(chunk);
}
const fileContent = Buffer.concat(chunks);
List Objects:
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
const response = await s3.send(new ListObjectsV2Command({
Bucket: 'my-bucket',
Prefix: 'folder/',
MaxKeys: 100,
}));
for (const object of response.Contents || []) {
console.log(`${object.Key} - ${object.Size} bytes`);
}
Delete Object:
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new DeleteObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
}));
Generate Presigned URL:
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
// Upload URL (expires in 5 minutes)
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
ContentType: 'text/plain',
}),
{ expiresIn: 300 }
);
// Download URL (expires in 1 hour)
const downloadUrl = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
}),
{ expiresIn: 3600 }
);
Admin Operations (using Admin API from Node.js):
// Create key via Admin API
const createKeyResponse = await fetch('http://cafe-1:3903/v1/key', {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: 'my-app-key' }),
});
const keyData = await createKeyResponse.json();
console.log('Key ID:', keyData.accessKeyId);
console.log('Secret:', keyData.secretAccessKey);
// Grant bucket permissions via Admin API
await fetch('http://cafe-1:3903/v1/bucket/allow', {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
bucketId: 'bucket-id-here',
accessKeyId: keyData.accessKeyId,
permissions: {
read: true,
write: true,
owner: true,
},
}),
});
Note: Node.js AWS SDK cannot manage keys or bucket permissions directly - use fetch() to call the Garage Admin API for those operations.
Once you’ve completed these prerequisites:
.env fileYou’re ready to proceed with the Node.js integration below.
React App → Express API → Garage S3 API
↓
Admin API (health checks)
Flow:
Why middleware instead of direct access?
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner express dotenv
npm install --save-dev @types/express @types/node typescript
Recommended SDK: AWS SDK v3 for JavaScript
Create .env:
# Garage Configuration
GARAGE_ENDPOINT=http://your-node:3900
GARAGE_REGION=garage
GARAGE_ACCESS_KEY_ID=GKxxxxxxxxxxxxxxxxxxxx
GARAGE_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Admin API (for health checks)
GARAGE_ADMIN_ENDPOINT=http://your-node:3903
GARAGE_ADMIN_TOKEN=your-admin-token
# Application
PORT=3001
NODE_ENV=production
// src/config/garage.ts
import { S3Client } from '@aws-sdk/client-s3';
const garageConfig = {
endpoint: process.env.GARAGE_ENDPOINT,
region: process.env.GARAGE_REGION || 'garage',
credentials: {
accessKeyId: process.env.GARAGE_ACCESS_KEY_ID!,
secretAccessKey: process.env.GARAGE_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // Required for Garage!
};
export const s3Client = new S3Client(garageConfig);
// Health check client
export const adminConfig = {
endpoint: process.env.GARAGE_ADMIN_ENDPOINT,
token: process.env.GARAGE_ADMIN_TOKEN,
};
Critical Setting: forcePathStyle: true - Garage requires path-style URLs (http://endpoint/bucket/key) instead of virtual-host style.
// src/services/bucket.service.ts
import {
CreateBucketCommand,
HeadBucketCommand,
ListBucketsCommand,
DeleteBucketCommand,
} from '@aws-sdk/client-s3';
import { s3Client } from '../config/garage';
export class BucketService {
/**
* Create a bucket if it doesn't exist
*/
async ensureBucket(bucketName: string): Promise<void> {
try {
await s3Client.send(new HeadBucketCommand({ Bucket: bucketName }));
console.log(`Bucket ${bucketName} already exists`);
} catch (error: any) {
if (error.name === 'NotFound') {
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
console.log(`Created bucket ${bucketName}`);
} else {
throw error;
}
}
}
/**
* List all buckets
*/
async listBuckets(): Promise<string[]> {
const response = await s3Client.send(new ListBucketsCommand({}));
return response.Buckets?.map(b => b.Name!).filter(Boolean) || [];
}
/**
* Delete a bucket (must be empty)
*/
async deleteBucket(bucketName: string): Promise<void> {
await s3Client.send(new DeleteBucketCommand({ Bucket: bucketName }));
}
}
For multi-tenant or organized storage:
// src/utils/bucket-naming.ts
export class BucketNamingStrategy {
/**
* Generate bucket name for a specific user/tenant
*/
static userBucket(userId: string): string {
return `user-${userId.toLowerCase()}`;
}
/**
* Generate bucket name for application data by type
*/
static appBucket(dataType: string): string {
return `app-${dataType.toLowerCase()}`;
}
/**
* Generate bucket name for time-series data
*/
static dateBucket(prefix: string, date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${prefix}-${year}-${month}`;
}
}
// Usage examples:
// BucketNamingStrategy.userBucket('user123') → 'user-user123'
// BucketNamingStrategy.appBucket('uploads') → 'app-uploads'
// BucketNamingStrategy.dateBucket('logs', new Date()) → 'logs-2025-11'
Benefits:
// src/services/storage.service.ts
import { BucketNamingStrategy } from '../utils/bucket-naming';
export class StorageService {
private bucketService = new BucketService();
private bucketPrefix: string;
constructor(bucketPrefix: string = 'data') {
this.bucketPrefix = bucketPrefix;
}
/**
* Get or create bucket for a specific date
*/
async getBucketForDate(date: Date): Promise<string> {
const bucketName = BucketNamingStrategy.dateBucket(this.bucketPrefix, date);
await this.bucketService.ensureBucket(bucketName);
return bucketName;
}
/**
* Get bucket name and key for timestamped object
*/
async getStorageLocation(objectId: string, timestamp: Date = new Date()): Promise<{
bucket: string;
key: string;
}> {
const bucket = await this.getBucketForDate(timestamp);
// Further partition by day within the bucket
const year = timestamp.getFullYear();
const month = String(timestamp.getMonth() + 1).padStart(2, '0');
const day = String(timestamp.getDate()).padStart(2, '0');
const key = `${year}/${month}/${day}/${objectId}`;
return { bucket, key };
}
/**
* List all buckets within a date range
*/
async getBucketsInRange(startDate: Date, endDate: Date): Promise<string[]> {
const buckets: string[] = [];
const current = new Date(startDate);
while (current <= endDate) {
buckets.push(BucketNamingStrategy.dateBucket(this.bucketPrefix, current));
current.setMonth(current.getMonth() + 1);
}
return buckets;
}
}
Partitioning Structure Example:
data-2025-11/ ← Monthly bucket
├── 2025/11/15/ ← Daily prefix
│ ├── file1.jpg
│ └── file2.pdf
├── 2025/11/16/
│ └── file3.txt
...
// src/services/object.service.ts
import { PutObjectCommand, PutObjectCommandInput } from '@aws-sdk/client-s3';
import { s3Client } from '../config/garage';
import { StorageService } from './storage.service';
import { createHash } from 'crypto';
import { Readable } from 'stream';
export class ObjectService {
private storageService = new StorageService();
/**
* Upload an object with automatic date-based partitioning
*/
async uploadObject(params: {
fileId: string;
data: Buffer | Readable;
contentType?: string;
metadata?: Record<string, string>;
timestamp?: Date;
}): Promise<{ bucket: string; key: string; etag?: string }> {
const { fileId, data, contentType, metadata, timestamp = new Date() } = params;
// Get storage location based on timestamp
const { bucket, key } = await this.storageService.getStorageLocation(
fileId,
timestamp
);
// Calculate MD5 hash for integrity
const hash = createHash('md5').update(data).digest('base64');
const uploadParams: PutObjectCommandInput = {
Bucket: bucket,
Key: key,
Body: data,
ContentType: contentType || 'application/octet-stream',
ContentMD5: hash,
Metadata: {
uploadedAt: timestamp.toISOString(),
...metadata,
},
};
const result = await s3Client.send(new PutObjectCommand(uploadParams));
return {
bucket,
key,
etag: result.ETag,
};
}
/**
* Upload with automatic retry
*/
async uploadWithRetry(
params: Parameters<typeof this.uploadObject>[0],
maxRetries: number = 3
): Promise<ReturnType<typeof this.uploadObject>> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await this.uploadObject(params);
} catch (error) {
lastError = error as Error;
console.error(`Upload attempt ${attempt + 1} failed:`, error);
if (attempt < maxRetries - 1) {
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
}
throw lastError;
}
}
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'stream';
export class ObjectService {
/**
* Download an object
*/
async downloadObject(bucket: string, key: string): Promise<{
data: Readable;
contentType?: string;
metadata?: Record<string, string>;
lastModified?: Date;
}> {
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
const response = await s3Client.send(command);
return {
data: response.Body as Readable,
contentType: response.ContentType,
metadata: response.Metadata,
lastModified: response.LastModified,
};
}
/**
* Check if object exists
*/
async objectExists(bucket: string, key: string): Promise<boolean> {
try {
await s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return true;
} catch (error: any) {
if (error.name === 'NotFound') {
return false;
}
throw error;
}
}
/**
* Get object metadata without downloading
*/
async getObjectMetadata(bucket: string, key: string): Promise<{
size: number;
contentType?: string;
lastModified?: Date;
metadata?: Record<string, string>;
}> {
const command = new HeadObjectCommand({ Bucket: bucket, Key: key });
const response = await s3Client.send(command);
return {
size: response.ContentLength || 0,
contentType: response.ContentType,
lastModified: response.LastModified,
metadata: response.Metadata,
};
}
}
import { ListObjectsV2Command, _Object } from '@aws-sdk/client-s3';
export class ObjectService {
/**
* List objects with pagination
*/
async listObjects(params: {
bucket: string;
prefix?: string;
maxKeys?: number;
continuationToken?: string;
}): Promise<{
objects: _Object[];
nextToken?: string;
isTruncated: boolean;
}> {
const command = new ListObjectsV2Command({
Bucket: params.bucket,
Prefix: params.prefix,
MaxKeys: params.maxKeys || 1000,
ContinuationToken: params.continuationToken,
});
const response = await s3Client.send(command);
return {
objects: response.Contents || [],
nextToken: response.NextContinuationToken,
isTruncated: response.IsTruncated || false,
};
}
/**
* List all objects in date range (across multiple buckets)
*/
async listObjectsInDateRange(params: {
startDate: Date;
endDate: Date;
prefix?: string;
}): Promise<Array<{ bucket: string; object: _Object }>> {
const buckets = await this.storageService.getBucketsInRange(
params.startDate,
params.endDate
);
const results: Array<{ bucket: string; object: _Object }> = [];
for (const bucket of buckets) {
try {
let continuationToken: string | undefined;
do {
const response = await this.listObjects({
bucket,
prefix: params.prefix,
continuationToken,
});
results.push(
...response.objects.map(obj => ({ bucket, object: obj }))
);
continuationToken = response.nextToken;
} while (continuationToken);
} catch (error: any) {
if (error.name !== 'NoSuchBucket') {
throw error;
}
// Bucket doesn't exist yet, skip it
}
}
return results;
}
}
import {
DeleteObjectCommand,
DeleteObjectsCommand,
ObjectIdentifier,
} from '@aws-sdk/client-s3';
export class ObjectService {
/**
* Delete a single object
*/
async deleteObject(bucket: string, key: string): Promise<void> {
await s3Client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
}
/**
* Delete multiple objects (batch operation)
* More efficient than deleting one by one
*/
async deleteObjects(
bucket: string,
keys: string[]
): Promise<{ deleted: string[]; errors: Array<{ key: string; error: string }> }> {
if (keys.length === 0) {
return { deleted: [], errors: [] };
}
// S3 batch delete limit is 1000 objects
const BATCH_SIZE = 1000;
const deleted: string[] = [];
const errors: Array<{ key: string; error: string }> = [];
for (let i = 0; i < keys.length; i += BATCH_SIZE) {
const batch = keys.slice(i, i + BATCH_SIZE);
const objects: ObjectIdentifier[] = batch.map(key => ({ Key: key }));
const command = new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: objects },
});
const response = await s3Client.send(command);
if (response.Deleted) {
deleted.push(...response.Deleted.map(d => d.Key!).filter(Boolean));
}
if (response.Errors) {
errors.push(
...response.Errors.map(e => ({
key: e.Key!,
error: `${e.Code}: ${e.Message}`,
}))
);
}
}
return { deleted, errors };
}
/**
* Delete all objects in a bucket (with confirmation)
*/
async deleteAllObjects(bucket: string): Promise<number> {
let deletedCount = 0;
let continuationToken: string | undefined;
do {
const listResponse = await this.listObjects({
bucket,
maxKeys: 1000,
continuationToken,
});
if (listResponse.objects.length > 0) {
const keys = listResponse.objects.map(obj => obj.Key!).filter(Boolean);
const { deleted } = await this.deleteObjects(bucket, keys);
deletedCount += deleted.length;
}
continuationToken = listResponse.nextToken;
} while (continuationToken);
return deletedCount;
}
/**
* Delete old data based on date threshold
*/
async deleteOldData(olderThan: Date): Promise<number> {
const buckets = await this.storageService.getBucketsInRange(
new Date(0), // Beginning of time
olderThan
);
let totalDeleted = 0;
for (const bucket of buckets) {
try {
const count = await this.deleteAllObjects(bucket);
totalDeleted += count;
// Optionally delete the bucket itself
await this.bucketService.deleteBucket(bucket);
console.log(`Deleted bucket ${bucket} with ${count} objects`);
} catch (error: any) {
if (error.name !== 'NoSuchBucket') {
console.error(`Error deleting bucket ${bucket}:`, error);
}
}
}
return totalDeleted;
}
}
For direct client uploads/downloads:
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
export class ObjectService {
/**
* Generate presigned URL for upload (client can upload directly)
*/
async generateUploadUrl(params: {
bucket: string;
key: string;
contentType?: string;
expiresIn?: number; // seconds
}): Promise<string> {
const command = new PutObjectCommand({
Bucket: params.bucket,
Key: params.key,
ContentType: params.contentType,
});
return await getSignedUrl(s3Client, command, {
expiresIn: params.expiresIn || 3600, // 1 hour default
});
}
/**
* Generate presigned URL for download
*/
async generateDownloadUrl(params: {
bucket: string;
key: string;
expiresIn?: number;
}): Promise<string> {
const command = new GetObjectCommand({
Bucket: params.bucket,
Key: params.key,
});
return await getSignedUrl(s3Client, command, {
expiresIn: params.expiresIn || 3600,
});
}
}
// src/services/health.service.ts
export interface ClusterHealth {
status: 'healthy' | 'degraded' | 'unhealthy';
nodes: NodeHealth[];
layout: {
version: number;
roles: number;
};
timestamp: Date;
}
export interface NodeHealth {
id: string;
addr: string;
isUp: boolean;
lastSeenSecsAgo: number;
hostname?: string;
zone?: string;
}
export class HealthService {
private adminEndpoint: string;
private adminToken: string;
constructor() {
this.adminEndpoint = process.env.GARAGE_ADMIN_ENDPOINT!;
this.adminToken = process.env.GARAGE_ADMIN_TOKEN!;
}
/**
* Get cluster health status
*/
async getClusterHealth(): Promise<ClusterHealth> {
const [statusResponse, layoutResponse] = await Promise.all([
this.fetchAdminAPI('/v1/status'),
this.fetchAdminAPI('/v1/layout'),
]);
const nodes: NodeHealth[] = Object.entries(statusResponse.nodes).map(
([id, node]: [string, any]) => ({
id,
addr: node.addr,
isUp: node.is_up,
lastSeenSecsAgo: node.last_seen_secs_ago,
hostname: node.hostname,
zone: node.zone,
})
);
const unhealthyNodes = nodes.filter(n => !n.isUp).length;
const status: ClusterHealth['status'] =
unhealthyNodes === 0 ? 'healthy' :
unhealthyNodes < nodes.length / 2 ? 'degraded' :
'unhealthy';
return {
status,
nodes,
layout: {
version: layoutResponse.version,
roles: Object.keys(layoutResponse.roles || {}).length,
},
timestamp: new Date(),
};
}
/**
* Check if cluster is healthy
*/
async isHealthy(): Promise<boolean> {
try {
const health = await this.getClusterHealth();
return health.status === 'healthy';
} catch (error) {
console.error('Health check failed:', error);
return false;
}
}
/**
* Get storage metrics
*/
async getMetrics(): Promise<{
totalSpace: number;
usedSpace: number;
availableSpace: number;
objectCount: number;
}> {
// Note: Garage metrics endpoint would be /metrics in Prometheus format
// For now, approximate by querying buckets
const buckets = await new BucketService().listBuckets();
// This is a simplified version - in production you'd parse metrics endpoint
return {
totalSpace: 0, // Would come from metrics
usedSpace: 0,
availableSpace: 0,
objectCount: 0,
};
}
private async fetchAdminAPI(path: string): Promise<any> {
const response = await fetch(`${this.adminEndpoint}${path}`, {
headers: {
'Authorization': `Bearer ${this.adminToken}`,
},
});
if (!response.ok) {
throw new Error(`Admin API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
}
// ❌ NEVER do this - credentials exposed!
app.get('/api/config', (req, res) => {
res.json({
garageEndpoint: process.env.GARAGE_ENDPOINT,
accessKeyId: process.env.GARAGE_ACCESS_KEY_ID, // ❌ DON'T
secretAccessKey: process.env.GARAGE_SECRET_ACCESS_KEY, // ❌ DON'T
});
});
// ✅ Do this - use presigned URLs
app.post('/api/upload-url', authenticateUser, async (req, res) => {
const { filename, contentType } = req.body;
const userId = req.user.id;
const location = await storageService.getStorageLocation(
`${userId}/${filename}`,
new Date()
);
const uploadUrl = await objectService.generateUploadUrl({
bucket: location.bucket,
key: location.key,
contentType,
expiresIn: 300, // 5 minutes
});
res.json({ uploadUrl, bucket: location.bucket, key: location.key });
});
import { z } from 'zod';
const UploadSchema = z.object({
filename: z.string().min(1).max(255).regex(/^[a-zA-Z0-9._-]+$/),
contentType: z.string().regex(/^[a-z]+\/[a-z0-9.+-]+$/),
size: z.number().positive().max(100 * 1024 * 1024), // 100MB
});
app.post('/api/upload', async (req, res) => {
try {
const validated = UploadSchema.parse(req.body);
// Process upload...
} catch (error) {
res.status(400).json({ error: 'Invalid input' });
}
});
import rateLimit from 'express-rate-limit';
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 uploads per window
message: 'Too many uploads, please try again later',
});
app.post('/api/upload', uploadLimiter, async (req, res) => {
// Handle upload
});
class AccessControlService {
/**
* Check if user can access object
*/
canAccessObject(userId: string, bucket: string, key: string): boolean {
// Implement your access control logic
// Example: user can only access their own files
return key.startsWith(`${userId}/`);
}
/**
* Get allowed bucket for user
*/
getUserBucket(userId: string): string {
return BucketNamingStrategy.userBucket(userId);
}
}
// src/index.ts
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import multer from 'multer';
import { ObjectService } from './services/object.service';
import { HealthService } from './services/health.service';
import { StorageService } from './services/storage.service';
dotenv.config();
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
app.use(cors());
app.use(express.json());
const objectService = new ObjectService();
const healthService = new HealthService();
const storageService = new StorageService('user-data');
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
const health = await healthService.getClusterHealth();
res.json(health);
} catch (error) {
res.status(500).json({ error: 'Health check failed' });
}
});
// Upload endpoint (multipart)
app.post('/api/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
const userId = req.headers['x-user-id'] as string; // From auth middleware
const fileId = `${userId}/${Date.now()}-${req.file.originalname}`;
const result = await objectService.uploadWithRetry({
fileId,
data: req.file.buffer,
contentType: req.file.mimetype,
metadata: {
originalName: req.file.originalname,
userId,
},
});
res.json({
success: true,
bucket: result.bucket,
key: result.key,
etag: result.etag,
});
} catch (error: any) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Upload failed', message: error.message });
}
});
// Get presigned upload URL
app.post('/api/upload-url', async (req, res) => {
try {
const { filename, contentType } = req.body;
const userId = req.headers['x-user-id'] as string;
const location = await storageService.getStorageLocation(
`${userId}/${filename}`,
new Date()
);
const uploadUrl = await objectService.generateUploadUrl({
bucket: location.bucket,
key: location.key,
contentType,
expiresIn: 300,
});
res.json({
uploadUrl,
bucket: location.bucket,
key: location.key,
});
} catch (error: any) {
res.status(500).json({ error: 'Failed to generate upload URL' });
}
});
// Download endpoint
app.get('/api/download/:bucket/:key(*)', async (req, res) => {
try {
const { bucket, key } = req.params;
const userId = req.headers['x-user-id'] as string;
// Validate access
if (!key.startsWith(`${userId}/`)) {
return res.status(403).json({ error: 'Access denied' });
}
const object = await objectService.downloadObject(bucket, key);
if (object.contentType) {
res.setHeader('Content-Type', object.contentType);
}
object.data.pipe(res);
} catch (error: any) {
if (error.name === 'NoSuchKey') {
return res.status(404).json({ error: 'File not found' });
}
res.status(500).json({ error: 'Download failed' });
}
});
// List user's files
app.get('/api/files', async (req, res) => {
try {
const userId = req.headers['x-user-id'] as string;
const { startDate, endDate } = req.query;
const start = startDate ? new Date(startDate as string) : new Date(0);
const end = endDate ? new Date(endDate as string) : new Date();
const objects = await objectService.listObjectsInDateRange({
startDate: start,
endDate: end,
prefix: `${userId}/`,
});
const files = objects.map(({ bucket, object }) => ({
bucket,
key: object.Key,
size: object.Size,
lastModified: object.LastModified,
etag: object.ETag,
}));
res.json({ files });
} catch (error) {
res.status(500).json({ error: 'Failed to list files' });
}
});
// Delete endpoint
app.delete('/api/files/:bucket/:key(*)', async (req, res) => {
try {
const { bucket, key } = req.params;
const userId = req.headers['x-user-id'] as string;
if (!key.startsWith(`${userId}/`)) {
return res.status(403).json({ error: 'Access denied' });
}
await objectService.deleteObject(bucket, key);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Delete failed' });
}
});
// Cleanup old data (admin endpoint)
app.post('/api/admin/cleanup', async (req, res) => {
try {
const { daysOld } = req.body;
const threshold = new Date();
threshold.setDate(threshold.getDate() - daysOld);
const deletedCount = await objectService.deleteOldData(threshold);
res.json({ success: true, deletedCount });
} catch (error) {
res.status(500).json({ error: 'Cleanup failed' });
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// src/services/storage.api.ts
const API_BASE = 'http://localhost:3001/api';
export class StorageAPI {
/**
* Upload file directly (multipart)
*/
static async uploadFile(file: File, userId: string): Promise<void> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: {
'X-User-Id': userId,
},
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
return response.json();
}
/**
* Upload file via presigned URL (recommended for large files)
*/
static async uploadViaPresignedUrl(
file: File,
userId: string
): Promise<void> {
// Get presigned URL from backend
const urlResponse = await fetch(`${API_BASE}/upload-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId,
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
});
const { uploadUrl } = await urlResponse.json();
// Upload directly to Garage
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
}
/**
* List files
*/
static async listFiles(userId: string, startDate?: Date, endDate?: Date) {
const params = new URLSearchParams();
if (startDate) params.append('startDate', startDate.toISOString());
if (endDate) params.append('endDate', endDate.toISOString());
const response = await fetch(`${API_BASE}/files?${params}`, {
headers: {
'X-User-Id': userId,
},
});
return response.json();
}
/**
* Delete file
*/
static async deleteFile(bucket: string, key: string, userId: string) {
const response = await fetch(`${API_BASE}/files/${bucket}/${key}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId,
},
});
return response.json();
}
/**
* Get cluster health
*/
static async getHealth() {
const response = await fetch(`${API_BASE}/health`);
return response.json();
}
}
deleteObjects() instead of multiple deleteObject()class GarageError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
public originalError?: any
) {
super(message);
this.name = 'GarageError';
}
}
function handleS3Error(error: any): never {
if (error.name === 'NoSuchBucket') {
throw new GarageError('Bucket not found', 'NOT_FOUND', 404, error);
}
if (error.name === 'NoSuchKey') {
throw new GarageError('Object not found', 'NOT_FOUND', 404, error);
}
if (error.name === 'AccessDenied') {
throw new GarageError('Access denied', 'FORBIDDEN', 403, error);
}
throw new GarageError(
'Storage operation failed',
'INTERNAL_ERROR',
500,
error
);
}