Fula API

Platform Integration Guides

Fula is S3-compatible, so you can use any S3 SDK. Here's how to integrate with popular platforms.

🌐 Next.js / React

Use the AWS SDK for JavaScript v3 in your Next.js application for both client and server components.

Installation

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Use Cases

  • Server Actions - Upload from API routes
  • Presigned URLs - Direct browser uploads
  • Server Components - Fetch files server-side
  • Edge Runtime - Works with Edge functions

Environment Variables

Add to .env.local:

.env.local
FULA_ENDPOINT=http://localhost:9000
FULA_ACCESS_KEY=your-access-key
FULA_SECRET_KEY=your-secret-key
FULA_BUCKET=my-bucket
lib/fula.ts - S3 Client
import { S3Client } from '@aws-sdk/client-s3';

export const fulaClient = new S3Client({
  endpoint: process.env.FULA_ENDPOINT,
  region: 'us-east-1',
  credentials: {
    accessKeyId: process.env.FULA_ACCESS_KEY!,
    secretAccessKey: process.env.FULA_SECRET_KEY!,
  },
  forcePathStyle: true,
});
app/api/upload/route.ts - API Route
import { NextRequest, NextResponse } from 'next/server';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { fulaClient } from '@/lib/fula';

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get('file') as File;
  
  const buffer = Buffer.from(await file.arrayBuffer());
  
  await fulaClient.send(new PutObjectCommand({
    Bucket: process.env.FULA_BUCKET,
    Key: `uploads/${file.name}`,
    Body: buffer,
    ContentType: file.type,
  }));

  return NextResponse.json({ 
    success: true,
    key: `uploads/${file.name}` 
  });
}
Presigned URL for Direct Upload
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';

// Generate upload URL (valid 1 hour)
export async function getUploadUrl(key: string) {
  const command = new PutObjectCommand({
    Bucket: process.env.FULA_BUCKET,
    Key: key,
  });
  return getSignedUrl(fulaClient, command, { expiresIn: 3600 });
}

// Generate download URL
export async function getDownloadUrl(key: string) {
  const command = new GetObjectCommand({
    Bucket: process.env.FULA_BUCKET,
    Key: key,
  });
  return getSignedUrl(fulaClient, command, { expiresIn: 3600 });
}

// Client-side: Upload directly to Fula
const uploadUrl = await getUploadUrl('my-file.pdf');
await fetch(uploadUrl, {
  method: 'PUT',
  body: file,
  headers: { 'Content-Type': file.type },
});

📱 React Native (iOS & Android)

Use react-native-blob-util for file handling with AWS SDK or direct HTTP calls.

Installation

npm install aws-sdk react-native-blob-util

Approaches

  • Presigned URLs - Recommended for mobile
  • Backend Proxy - Upload via your server
  • Direct SDK - AWS SDK v2 works in RN

Note

AWS SDK v3 has issues with React Native. Use v2 or presigned URLs.

fulaClient.ts
import AWS from 'aws-sdk';

const fulaS3 = new AWS.S3({
  endpoint: 'https://your-fula-gateway.com',
  accessKeyId: 'YOUR_ACCESS_KEY',
  secretAccessKey: 'YOUR_SECRET_KEY',
  s3ForcePathStyle: true,
  signatureVersion: 'v4',
});

export default fulaS3;
Upload with Presigned URL
import ReactNativeBlobUtil from 'react-native-blob-util';

// Get presigned URL from your backend
const response = await fetch('https://your-api.com/upload-url', {
  method: 'POST',
  body: JSON.stringify({ filename: 'photo.jpg' }),
});
const { uploadUrl } = await response.json();

// Upload file directly to Fula
const filePath = '/path/to/photo.jpg';
await ReactNativeBlobUtil.fetch('PUT', uploadUrl, {
  'Content-Type': 'image/jpeg',
}, ReactNativeBlobUtil.wrap(filePath));

console.log('Upload complete!');
Upload Image from Camera
import { launchCamera } from 'react-native-image-picker';
import fulaS3 from './fulaClient';

async function uploadPhoto() {
  const result = await launchCamera({ mediaType: 'photo' });
  
  if (result.assets?.[0]) {
    const asset = result.assets[0];
    const response = await fetch(asset.uri!);
    const blob = await response.blob();
    
    await fulaS3.upload({
      Bucket: 'photos',
      Key: `camera/${Date.now()}.jpg`,
      Body: blob,
      ContentType: 'image/jpeg',
    }).promise();
    
    console.log('Photo uploaded!');
  }
}

// Download file
async function downloadFile(key: string) {
  const url = fulaS3.getSignedUrl('getObject', {
    Bucket: 'photos',
    Key: key,
    Expires: 3600,
  });
  
  const res = await ReactNativeBlobUtil.config({
    fileCache: true,
    appendExt: 'jpg',
  }).fetch('GET', url);
  
  return res.path();
}

🪟 .NET / C# (Windows, MAUI, Blazor)

Use the official AWS SDK for .NET. Works with Windows apps, ASP.NET, MAUI, and Blazor.

Installation

dotnet add package AWSSDK.S3

Platforms

  • ASP.NET Core - Web APIs and MVC
  • Blazor - Server and WASM
  • MAUI - Cross-platform mobile/desktop
  • WPF/WinForms - Windows desktop
  • Console - CLI tools
FulaClient.cs
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.Runtime;

public class FulaClient
{
    private readonly IAmazonS3 _s3Client;
    private readonly string _bucketName;

    public FulaClient(string endpoint, string accessKey, 
                      string secretKey, string bucket)
    {
        var config = new AmazonS3Config
        {
            ServiceURL = endpoint,
            ForcePathStyle = true,
            SignatureVersion = "4"
        };

        var credentials = new BasicAWSCredentials(accessKey, secretKey);
        _s3Client = new AmazonS3Client(credentials, config);
        _bucketName = bucket;
    }

    public async Task UploadFileAsync(string key, Stream stream, 
                                       string contentType)
    {
        var request = new PutObjectRequest
        {
            BucketName = _bucketName,
            Key = key,
            InputStream = stream,
            ContentType = contentType
        };

        await _s3Client.PutObjectAsync(request);
    }

    public async Task<Stream> DownloadFileAsync(string key)
    {
        var response = await _s3Client.GetObjectAsync(_bucketName, key);
        return response.ResponseStream;
    }

    public async Task<List<S3Object>> ListFilesAsync(string prefix = "")
    {
        var request = new ListObjectsV2Request
        {
            BucketName = _bucketName,
            Prefix = prefix
        };

        var response = await _s3Client.ListObjectsV2Async(request);
        return response.S3Objects;
    }

    public string GetPresignedUrl(string key, TimeSpan expiry)
    {
        var request = new GetPreSignedUrlRequest
        {
            BucketName = _bucketName,
            Key = key,
            Expires = DateTime.UtcNow.Add(expiry)
        };

        return _s3Client.GetPreSignedURL(request);
    }
}
ASP.NET Core Usage
// Program.cs - Dependency Injection
builder.Services.AddSingleton<FulaClient>(sp => 
    new FulaClient(
        endpoint: "http://localhost:9000",
        accessKey: builder.Configuration["Fula:AccessKey"]!,
        secretKey: builder.Configuration["Fula:SecretKey"]!,
        bucket: "my-bucket"
    ));

// Controller
[ApiController]
[Route("api/[controller]")]
public class FilesController : ControllerBase
{
    private readonly FulaClient _fula;

    public FilesController(FulaClient fula) => _fula = fula;

    [HttpPost("upload")]
    public async Task<IActionResult> Upload(IFormFile file)
    {
        var key = $"uploads/{Guid.NewGuid()}/{file.FileName}";
        await _fula.UploadFileAsync(key, file.OpenReadStream(), 
                                     file.ContentType);
        return Ok(new { key });
    }

    [HttpGet("download/{*key}")]
    public async Task<IActionResult> Download(string key)
    {
        var stream = await _fula.DownloadFileAsync(key);
        return File(stream, "application/octet-stream");
    }
}
MAUI Mobile App
// Upload photo from device
public async Task UploadPhotoAsync()
{
    var result = await MediaPicker.CapturePhotoAsync();
    if (result == null) return;

    using var stream = await result.OpenReadAsync();
    var key = $"photos/{DateTime.Now:yyyyMMdd}/{result.FileName}";
    
    await _fula.UploadFileAsync(key, stream, "image/jpeg");
    
    await DisplayAlert("Success", "Photo uploaded!", "OK");
}

🐦 Flutter / Dart

Use the minio package for S3-compatible storage in Flutter apps.

Installation

flutter pub add minio

Platforms

Works on iOS, Android, Web, Windows, macOS, and Linux.

fula_client.dart
import 'package:minio/minio.dart';
import 'dart:io';

class FulaClient {
  late final Minio _minio;
  final String bucket;

  FulaClient({
    required String endpoint,
    required String accessKey,
    required String secretKey,
    required this.bucket,
  }) {
    _minio = Minio(
      endPoint: endpoint,
      accessKey: accessKey,
      secretKey: secretKey,
      useSSL: endpoint.startsWith('https'),
    );
  }

  Future<void> uploadFile(String key, File file) async {
    await _minio.fPutObject(bucket, key, file.path);
  }

  Future<void> uploadBytes(String key, List<int> data, 
                            String contentType) async {
    await _minio.putObject(
      bucket,
      key,
      Stream.value(data),
      size: data.length,
      metadata: {'Content-Type': contentType},
    );
  }

  Future<List<int>> downloadFile(String key) async {
    final stream = await _minio.getObject(bucket, key);
    final chunks = <int>[];
    await for (final chunk in stream) {
      chunks.addAll(chunk);
    }
    return chunks;
  }

  Future<List<Object>> listFiles(String prefix) async {
    final objects = <Object>[];
    await for (final obj in _minio.listObjects(bucket, prefix: prefix)) {
      objects.addAll(obj.objects);
    }
    return objects;
  }

  Future<String> getPresignedUrl(String key) async {
    return await _minio.presignedGetObject(bucket, key);
  }
}
Usage in Flutter
import 'package:image_picker/image_picker.dart';

final fula = FulaClient(
  endpoint: 'your-fula-gateway.com',
  accessKey: 'YOUR_ACCESS_KEY',
  secretKey: 'YOUR_SECRET_KEY',
  bucket: 'my-bucket',
);

// Upload image from gallery
Future<void> uploadImage() async {
  final picker = ImagePicker();
  final image = await picker.pickImage(source: ImageSource.gallery);
  
  if (image != null) {
    final file = File(image.path);
    final key = 'images/${DateTime.now().millisecondsSinceEpoch}.jpg';
    
    await fula.uploadFile(key, file);
    print('Uploaded: $key');
  }
}

// Display image from Fula
Widget buildImage(String key) {
  return FutureBuilder<String>(
    future: fula.getPresignedUrl(key),
    builder: (context, snapshot) {
      if (snapshot.hasData) {
        return Image.network(snapshot.data!);
      }
      return CircularProgressIndicator();
    },
  );
}

🍎 Swift / iOS

Use the AWS SDK for Swift or Soto (community SDK) for iOS/macOS apps.

Installation (SPM)

https://github.com/soto-project/soto.git

Recommended

Soto is lighter and more Swift-native than the official AWS SDK.

FulaClient.swift
import SotoS3
import NIO

class FulaClient {
    private let s3: S3
    private let bucket: String
    
    init(endpoint: String, accessKey: String, 
         secretKey: String, bucket: String) {
        let client = AWSClient(
            credentialProvider: .static(
                accessKeyId: accessKey,
                secretAccessKey: secretKey
            ),
            httpClientProvider: .createNew
        )
        
        self.s3 = S3(
            client: client,
            endpoint: endpoint,
            timeout: .seconds(30)
        )
        self.bucket = bucket
    }
    
    func upload(key: String, data: Data, 
                contentType: String) async throws {
        let request = S3.PutObjectRequest(
            body: .data(data),
            bucket: bucket,
            contentType: contentType,
            key: key
        )
        _ = try await s3.putObject(request)
    }
    
    func download(key: String) async throws -> Data {
        let request = S3.GetObjectRequest(bucket: bucket, key: key)
        let response = try await s3.getObject(request)
        
        guard let body = response.body?.asData() else {
            throw NSError(domain: "FulaError", code: 1)
        }
        return body
    }
    
    func listObjects(prefix: String) async throws -> [S3.Object] {
        let request = S3.ListObjectsV2Request(
            bucket: bucket,
            prefix: prefix
        )
        let response = try await s3.listObjectsV2(request)
        return response.contents ?? []
    }
}
SwiftUI Usage
import SwiftUI
import PhotosUI

struct ContentView: View {
    @State private var selectedImage: PhotosPickerItem?
    let fula = FulaClient(
        endpoint: "https://your-fula-gateway.com",
        accessKey: "YOUR_KEY",
        secretKey: "YOUR_SECRET",
        bucket: "photos"
    )
    
    var body: some View {
        PhotosPicker(selection: $selectedImage, matching: .images) {
            Text("Upload Photo")
        }
        .onChange(of: selectedImage) { item in
            Task {
                if let data = try? await item?.loadTransferable(
                    type: Data.self
                ) {
                    let key = "photos/\(UUID().uuidString).jpg"
                    try? await fula.upload(
                        key: key,
                        data: data,
                        contentType: "image/jpeg"
                    )
                }
            }
        }
    }
}

🤖 Kotlin / Android

Use the AWS SDK for Kotlin or MinIO Java SDK for Android apps.

Gradle Dependency

implementation("io.minio:minio:8.5.7")

Note

MinIO SDK is lighter than AWS SDK for mobile use.

FulaClient.kt
import io.minio.MinioClient
import io.minio.PutObjectArgs
import io.minio.GetObjectArgs
import io.minio.ListObjectsArgs
import java.io.InputStream

class FulaClient(
    endpoint: String,
    accessKey: String,
    secretKey: String,
    private val bucket: String
) {
    private val client = MinioClient.builder()
        .endpoint(endpoint)
        .credentials(accessKey, secretKey)
        .build()

    suspend fun upload(
        key: String, 
        stream: InputStream, 
        size: Long,
        contentType: String
    ) {
        withContext(Dispatchers.IO) {
            client.putObject(
                PutObjectArgs.builder()
                    .bucket(bucket)
                    .`object`(key)
                    .stream(stream, size, -1)
                    .contentType(contentType)
                    .build()
            )
        }
    }

    suspend fun download(key: String): InputStream {
        return withContext(Dispatchers.IO) {
            client.getObject(
                GetObjectArgs.builder()
                    .bucket(bucket)
                    .`object`(key)
                    .build()
            )
        }
    }

    suspend fun listObjects(prefix: String): List<String> {
        return withContext(Dispatchers.IO) {
            client.listObjects(
                ListObjectsArgs.builder()
                    .bucket(bucket)
                    .prefix(prefix)
                    .build()
            ).map { it.get().objectName() }
        }
    }
}
Android Compose Usage
@Composable
fun UploadScreen() {
    val context = LocalContext.current
    val fula = remember {
        FulaClient(
            endpoint = "https://your-fula-gateway.com",
            accessKey = "YOUR_KEY",
            secretKey = "YOUR_SECRET",
            bucket = "uploads"
        )
    }

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.GetContent()
    ) { uri ->
        uri?.let {
            lifecycleScope.launch {
                val stream = context.contentResolver.openInputStream(uri)
                val key = "uploads/${System.currentTimeMillis()}.jpg"
                
                stream?.use { input ->
                    fula.upload(
                        key = key,
                        stream = input,
                        size = input.available().toLong(),
                        contentType = "image/jpeg"
                    )
                }
            }
        }
    }

    Button(onClick = { launcher.launch("image/*") }) {
        Text("Upload Image")
    }
}

📁 File Manager Pattern

Build file browsers that work with encrypted storage without downloading all file content.

The Challenge

With encrypted storage, you might think you need to download and decrypt every file just to show a file list. That's expensive!

The Solution

Fula encrypts metadata separately, allowing you to:

  • List files - Get filenames without downloading content
  • Show details - Size, type, timestamps from metadata only
  • Lazy load - Download content only when user opens file

Bandwidth Comparison

Operation Without Optimization With Metadata API
List 100 files (1GB total) Download 1GB ~100KB (headers)
Show file info Full download HEAD request
Rust - File Manager
use fula_client::{EncryptedClient, EncryptionConfig, Config};

async fn build_file_browser(client: &EncryptedClient, bucket: &str) {
    // Step 1: List all files (metadata only - fast!)
    let files = client.list_objects_decrypted(bucket, None).await?;
    
    // Step 2: Display in UI
    for file in &files {
        println!("📄 {} - {} - {:?}", 
            file.original_key,      // Real filename (decrypted)
            file.size_human(),      // "1.5 MB"  
            file.content_type       // "application/pdf"
        );
    }
    
    // Step 3: User clicks a file - NOW download content
    if let Some(selected) = user_selection {
        let content = client.get_object_decrypted_by_storage_key(
            bucket, 
            &selected.storage_key
        ).await?;
        open_file(content);
    }
}
TypeScript/React - File Browser Component
// For TypeScript/JavaScript apps, use the Rust SDK via WASM
// or implement the metadata pattern with HEAD requests

interface FileMetadata {
  storageKey: string;
  originalKey: string;
  size: number;
  contentType?: string;
  createdAt?: number;
}

// Efficient: Only fetch metadata headers
async function listFilesMetadataOnly(bucket: string): Promise {
  // 1. List object keys (S3 ListObjectsV2)
  const listResult = await s3.send(new ListObjectsV2Command({ Bucket: bucket }));
  
  // 2. HEAD each object to get metadata without content
  const files = await Promise.all(
    (listResult.Contents ?? []).map(async (obj) => {
      const head = await s3.send(new HeadObjectCommand({
        Bucket: bucket,
        Key: obj.Key!
      }));
      
      // Decrypt private metadata from x-amz-meta-encryption header
      const encMeta = head.Metadata?.['encryption'];
      const privateMeta = encMeta ? decryptMetadata(encMeta) : null;
      
      return {
        storageKey: obj.Key!,
        originalKey: privateMeta?.originalKey ?? obj.Key!,
        size: privateMeta?.actualSize ?? obj.Size!,
        contentType: privateMeta?.contentType,
      };
    })
  );
  
  return files;
}

// React component
function FileBrowser({ bucket }: { bucket: string }) {
  const [files, setFiles] = useState([]);
  
  useEffect(() => {
    listFilesMetadataOnly(bucket).then(setFiles);
  }, [bucket]);
  
  return (
    <div className="file-grid">
      {files.map(file => (
        <FileCard 
          key={file.storageKey}
          name={file.originalKey}
          size={formatSize(file.size)}
          type={file.contentType}
          onClick={() => downloadFile(file.storageKey)}
        />
      ))}
    </div>
  );
}
Flutter - File List
class FileMetadata {
  final String storageKey;
  final String originalKey;
  final int size;
  final String? contentType;
  
  FileMetadata({
    required this.storageKey,
    required this.originalKey,
    required this.size,
    this.contentType,
  });
}

class FulaFileBrowser extends StatefulWidget {
  @override
  _FulaFileBrowserState createState() => _FulaFileBrowserState();
}

class _FulaFileBrowserState extends State {
  List files = [];
  bool loading = true;
  
  @override
  void initState() {
    super.initState();
    loadFileMetadata();
  }
  
  Future loadFileMetadata() async {
    // Only fetch metadata, not file content
    final objects = await fulaClient.listObjects(bucket);
    
    final metadata = await Future.wait(
      objects.map((obj) => fulaClient.headObject(bucket, obj.key))
    );
    
    setState(() {
      files = metadata.map((m) => FileMetadata(
        storageKey: m.key,
        originalKey: decryptMetadata(m)?.originalKey ?? m.key,
        size: decryptMetadata(m)?.actualSize ?? m.size,
        contentType: decryptMetadata(m)?.contentType,
      )).toList();
      loading = false;
    });
  }
  
  @override
  Widget build(BuildContext context) {
    if (loading) return CircularProgressIndicator();
    
    return ListView.builder(
      itemCount: files.length,
      itemBuilder: (ctx, i) => ListTile(
        leading: Icon(getFileIcon(files[i].contentType)),
        title: Text(files[i].originalKey.split('/').last),
        subtitle: Text(formatSize(files[i].size)),
        onTap: () => downloadAndOpen(files[i].storageKey),
      ),
    );
  }
}

🔗 Secure Sharing Pattern

Share encrypted files without exposing your master key or giving permanent access.

Sharing Model

  • Path-Scoped - Share only specific folders
  • Time-Limited - Access expires automatically
  • Permission-Based - Read-only, read-write, or full
  • Revocable - Cancel access at any time

How It Works

  1. Owner creates a share token for recipient's public key
  2. DEK is re-encrypted for recipient (HPKE)
  3. Token sent via any channel (email, message, QR code)
  4. Recipient decrypts with their private key
  5. Owner's master key never exposed
Share Flow - Owner Side
use fula_crypto::sharing::{ShareBuilder, SharePermissions};

// Alice wants to share /photos/vacation with Bob
let share_token = ShareBuilder::new(
    &alice_keypair,         // Owner's keypair
    &bob_public_key,        // Recipient's public key
    &folder_dek,            // The folder's DEK
)
.with_path_scope("/photos/vacation/")
.with_expiry_days(30)  // Expires in 30 days
.with_permissions(SharePermissions::read_only())
.build()?;

// Send to Bob via any channel
let share_link = format!(
    "https://app.example.com/share?token={}", 
    base64_encode(&share_token)
);
Accept Share - Recipient Side
use fula_crypto::sharing::AcceptedShare;

// Bob receives the share token
let share_token = decode_share_token(&received_token)?;

// Verify and accept with Bob's private key
let accepted = AcceptedShare::accept(&share_token, &bob_keypair)?;

// Check access
if accepted.is_expired() {
    return Err("Share has expired");
}

if !accepted.has_access_to("/photos/vacation/beach.jpg") {
    return Err("Path not in share scope");
}

// Get the DEK to decrypt files
let dek = accepted.dek();

// Now Bob can read files in /photos/vacation/
let content = decrypt_file(&encrypted_data, &dek)?;
Mobile Share UI (React Native)
import { Share } from 'react-native';

async function shareFolder(folderPath: string, recipientPublicKey: string) {
  // Create share token using Fula crypto
  const shareToken = await fulaClient.createShareToken({
    path: folderPath,
    recipientKey: recipientPublicKey,
    expiryDays: 7,
    permissions: 'read',
  });
  
  // Share via system share sheet
  const shareUrl = `fula://share/${encodeURIComponent(shareToken)}`;
  
  await Share.share({
    message: `I'm sharing "${folderPath}" with you!`,
    url: shareUrl,
  });
}

// Accept share from deep link
async function handleShareLink(url: string) {
  const token = extractToken(url);
  
  try {
    const accepted = await fulaClient.acceptShare(token, myPrivateKey);
    
    Alert.alert(
      'Share Accepted!',
      `You now have ${accepted.permissions} access to ${accepted.pathScope}`,
      [{ text: 'Browse Files', onPress: () => navigateToFolder(accepted.pathScope) }]
    );
  } catch (e) {
    Alert.alert('Share Expired', 'This share link is no longer valid.');
  }
}