GameCraftGameCraft

File Upload & Attachments

Complete guide to implementing file uploads and attachments with S3 presigned URLs

File Upload & Attachments

ProductReady provides a comprehensive file upload system using S3 presigned URLs for secure, scalable file handling. This guide covers both the general upload infrastructure and specific implementations.

Architecture: Files are uploaded directly from client to S3 without passing through your server, saving bandwidth and improving performance.


Overview

The upload system consists of three main components:

  1. tRPC Attachments Router - Server-side presigned URL generation and validation
  2. Upload Hooks - Client-side React hooks for easy integration
  3. Storage Utilities - Shared utilities from sharelib/storage

Upload Flow

Client                    Server                     S3
  |                         |                         |
  |--requestUploadUrl------>|                         |
  |                         |                         |
  |<--presignedURL + key----|                         |
  |                         |                         |
  |--PUT file------------------------------>|         |
  |                         |                         |
  |<--200 OK--------------------------------|         |
  |                         |                         |
  |--confirmUpload (opt)--->|                         |
  |<--publicURL-------------|                         |

Quick Start

1. Environment Setup

Add S3 credentials to your .env file:

# S3 Storage Configuration
AWS_REGION=us-east-1
AWS_S3_BUCKET=your-bucket-name
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key

Security: Never commit .env files to version control. Use environment variables in production.

2. Basic Image Upload

Use the useImageUpload hook for RichEditor or other image upload scenarios:

import { useImageUpload } from "~/hooks/useImageUpload";

export function MyComponent() {
  const { handleImageUpload, uploading } = useImageUpload({
    context: "post",
    onError: (error) => console.error("Upload failed:", error),
  });

  const onFileSelect = async (file: File) => {
    try {
      const publicUrl = await handleImageUpload(file);
      console.log("Image uploaded:", publicUrl);
    } catch (error) {
      // Error already handled by onError callback
    }
  };

  return (
    <div>
      <input 
        type="file" 
        accept="image/*"
        onChange={(e) => e.target.files?.[0] && onFileSelect(e.target.files[0])}
        disabled={uploading}
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}

3. Attachment Upload (Multiple Files)

Use useAttachmentUpload for handling multiple attachments (e.g., in chat or forms):

import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";
import type { FileUIPart } from "ai";

export function ChatComponent() {
  const { handleFileUpload, uploading } = useAttachmentUpload({
    context: "general",
    onUploadStart: () => console.log("Upload started"),
    onUploadComplete: () => console.log("Upload completed"),
  });

  const onFilesSelected = async (files: FileList) => {
    // Convert FileList to FileUIPart[] format
    const fileParts: FileUIPart[] = Array.from(files).map(file => ({
      type: "file",
      filename: file.name,
      mimeType: file.type,
      url: URL.createObjectURL(file), // blob URL
    }));

    // Upload to S3 and get public URLs
    const uploadedFiles = await handleFileUpload(fileParts);
    console.log("Uploaded files:", uploadedFiles);
  };

  return (
    <input 
      type="file" 
      multiple
      onChange={(e) => e.target.files && onFilesSelected(e.target.files)}
      disabled={uploading}
    />
  );
}

Upload Hooks API

useImageUpload

Hook for uploading images (e.g., in RichEditor, profile pictures, blog posts).

Options

interface UseImageUploadOptions {
  /** Upload context for organizing files in S3 */
  context?: "post" | "general";
  /** Called on upload error */
  onError?: (error: Error) => void;
}

Returns

{
  handleImageUpload: (file: File) => Promise<string>; // Returns public URL
  uploading: boolean; // Upload state
}

Example: Profile Picture Upload

import { useImageUpload } from "~/hooks/useImageUpload";
import { useState } from "react";

export function ProfilePictureUpload() {
  const [avatarUrl, setAvatarUrl] = useState("");
  const { handleImageUpload, uploading } = useImageUpload({
    context: "general",
    onError: (error) => alert(error.message),
  });

  const onAvatarSelect = async (file: File) => {
    const url = await handleImageUpload(file);
    setAvatarUrl(url);
    // Save URL to user profile...
  };

  return (
    <div>
      {avatarUrl && <img src={avatarUrl} alt="Avatar" />}
      <input
        type="file"
        accept="image/*"
        onChange={(e) => e.target.files?.[0] && onAvatarSelect(e.target.files[0])}
        disabled={uploading}
      />
    </div>
  );
}

useAttachmentUpload

Hook for uploading multiple attachments (e.g., chat attachments, document uploads).

Options

interface UseAttachmentUploadOptions {
  /** Upload context for organizing files in S3 */
  context?: "post" | "general";
  /** Called when upload starts */
  onUploadStart?: () => void;
  /** Called when upload completes */
  onUploadComplete?: () => void;
  /** Called on error */
  onError?: (error: Error) => void;
}

Returns

{
  handleFileUpload: (files: FileUIPart[]) => Promise<FileUIPart[]>;
  uploading: boolean;
}

Example: Multi-file Upload

import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";

export function DocumentUploader() {
  const [attachments, setAttachments] = useState<string[]>([]);
  const { handleFileUpload, uploading } = useAttachmentUpload({
    context: "general",
    onUploadComplete: () => console.log("All files uploaded!"),
  });

  const onFilesSelected = async (files: FileList) => {
    const fileParts = Array.from(files).map(file => ({
      type: "file" as const,
      filename: file.name,
      mimeType: file.type,
      url: URL.createObjectURL(file),
    }));

    const uploaded = await handleFileUpload(fileParts);
    setAttachments(prev => [...prev, ...uploaded.map(f => f.url)]);
  };

  return (
    <div>
      <input
        type="file"
        multiple
        onChange={(e) => e.target.files && onFilesSelected(e.target.files)}
        disabled={uploading}
      />
      {uploading && <p>Uploading...</p>}
      <ul>
        {attachments.map((url, i) => (
          <li key={i}><a href={url} target="_blank" rel="noopener noreferrer">File {i + 1}</a></li>
        ))}
      </ul>
    </div>
  );
}

tRPC Attachments Router

The server-side router handles presigned URL generation and validation.

Endpoints

attachments.requestUploadUrl

Request a presigned upload URL for direct S3 upload.

Input:

{
  fileName: string;     // Original filename (max 255 chars)
  mimeType: string;     // File MIME type (e.g., "image/png")
  sizeBytes: number;    // File size in bytes (max 10MB)
  context?: "post" | "general"; // Optional context for organizing files
}

Output:

{
  uploadUrl: string;    // Presigned PUT URL (expires in 1 hour)
  storageKey: string;   // S3 key for the file
  publicUrl: string;    // Presigned GET URL (expires in 24 hours)
  expiresIn: number;    // Upload URL expiration in seconds
}

Usage:

import { trpc } from "~/lib/trpc/client";

const requestUploadUrl = trpc.attachments.requestUploadUrl.useMutation();

const uploadFile = async (file: File) => {
  // Step 1: Request presigned URL
  const { uploadUrl, publicUrl } = await requestUploadUrl.mutateAsync({
    fileName: file.name,
    mimeType: file.type,
    sizeBytes: file.size,
    context: "post",
  });

  // Step 2: Upload to S3
  const response = await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type },
  });

  if (!response.ok) {
    throw new Error("Upload failed");
  }

  // Step 3: Use public URL
  return publicUrl;
};

attachments.confirmUpload (Optional)

Confirm upload for tracking/auditing. This is optional—the file is already accessible via publicUrl.

Input:

{
  storageKey: string;   // S3 key from requestUploadUrl
  fileName: string;     // Original filename
  mimeType: string;     // File MIME type
  sizeBytes: number;    // File size
  context?: "post" | "general";
}

Output:

{
  success: boolean;
  storageKey: string;
  publicUrl: string;    // Fresh presigned URL (24 hours)
}

Storage Utilities

The sharelib/storage package provides reusable utilities for presigned URL workflows.

Import

import {
  uploadToS3WithPresignedUrl,
  extractFileExtension,
  validateFileSize,
  validateMimeType,
  formatBytes,
} from "sharelib/storage";

uploadToS3WithPresignedUrl

Upload file content to S3 using a presigned URL.

const response = await uploadToS3WithPresignedUrl(
  presignedUrl,
  file,        // Buffer | Blob | string | ArrayBuffer
  "image/png"
);

if (!response.ok) {
  throw new Error("Upload failed");
}

extractFileExtension

Extract file extension from filename.

extractFileExtension("document.pdf");     // Returns: "pdf"
extractFileExtension("archive.tar.gz");   // Returns: "gz"
extractFileExtension("no-extension");     // Returns: ""

validateFileSize

Validate file size against maximum limit.

const MAX_SIZE = 10 * 1024 * 1024; // 10MB
validateFileSize(file.size, MAX_SIZE); // Throws error if exceeds

validateMimeType

Validate MIME type against allowed types.

validateMimeType("image/png", [
  "image/*",          // Wildcard pattern
  "application/pdf",
]); // Throws error if not allowed

formatBytes

Format bytes to human-readable size.

formatBytes(1536);          // "1.5 KB"
formatBytes(1048576);       // "1 MB"
formatBytes(5242880, 0);    // "5 MB"

Security & Validation

File Size Limits

  • Default Maximum: 10MB per file
  • Enforcement: Server-side validation before presigned URL generation
  • Customization: Modify MAX_FILE_SIZE in src/server/routers/attachments.ts
// Server-side validation
if (input.sizeBytes > MAX_FILE_SIZE) {
  throw new TRPCError({
    code: "BAD_REQUEST",
    message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`,
  });
}

MIME Type Validation

Only images are allowed by default:

const allowedMimeTypes = [
  "image/jpeg",
  "image/jpg",
  "image/png",
  "image/gif",
  "image/webp",
  "image/svg+xml",
];

To support documents, uncomment in src/server/routers/attachments.ts:

// Uncomment to allow PDFs and Word documents
// "application/pdf",
// "application/msword",
// "application/vnd.openxmlformats-officedocument.wordprocessingml.document",

Authentication

All upload endpoints require authentication:

export const attachmentsRouter = createTRPCRouter({
  requestUploadUrl: protectedProcedure // Requires logged-in user
    .input(...)
    .mutation(...),
});

Storage Organization

Files are organized by user and context for isolation and auditing:

s3://bucket/attachments/{userId}/{context}/{nanoid}.{ext}

Examples:
- s3://bucket/attachments/user_abc123/post/vY3kL9mN4pQ2.png
- s3://bucket/attachments/user_xyz789/general/pQ2vY3kL9mN4.jpg

Advanced Usage

Custom File Upload Handler

Build your own upload handler using the underlying utilities:

import { trpc } from "~/lib/trpc/client";
import { uploadToS3WithPresignedUrl, validateFileSize } from "sharelib/storage";

export async function customUploadFile(file: File): Promise<string> {
  // Step 1: Client-side validation
  const MAX_SIZE = 50 * 1024 * 1024; // 50MB custom limit
  validateFileSize(file.size, MAX_SIZE);

  // Step 2: Request presigned URL
  const result = await trpc.attachments.requestUploadUrl.mutate({
    fileName: file.name,
    mimeType: file.type,
    sizeBytes: file.size,
    context: "general",
  });

  // Step 3: Upload to S3
  const response = await uploadToS3WithPresignedUrl(
    result.uploadUrl,
    file,
    file.type
  );

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`);
  }

  // Step 4: Return public URL
  return result.publicUrl;
}

Progress Tracking

Track upload progress with XMLHttpRequest:

import { trpc } from "~/lib/trpc/client";

export async function uploadWithProgress(
  file: File,
  onProgress: (percent: number) => void
): Promise<string> {
  // Get presigned URL
  const { uploadUrl, publicUrl } = await trpc.attachments.requestUploadUrl.mutate({
    fileName: file.name,
    mimeType: file.type,
    sizeBytes: file.size,
  });

  // Upload with progress tracking
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * 100;
        onProgress(percent);
      }
    });

    xhr.addEventListener("load", () => {
      if (xhr.status === 200) {
        resolve(publicUrl);
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener("error", () => reject(new Error("Upload failed")));

    xhr.open("PUT", uploadUrl);
    xhr.setRequestHeader("Content-Type", file.type);
    xhr.send(file);
  });
}

Custom Storage Paths

Modify storage key generation in the server router:

// Default: attachments/{userId}/{context}/{nanoid}.{ext}
const storageKey = `attachments/${userId}/${context}/${generateNanoID()}${ext ? `.${ext}` : ""}`;

// Custom: projects/{projectId}/documents/{timestamp}-{filename}
const storageKey = `projects/${projectId}/documents/${Date.now()}-${input.fileName}`;

Integration Examples

With RichEditor

See Rich Text Editor for detailed RichEditor integration with image upload support.

import { RichEditor } from "sharelib/richeditor";
import { useImageUpload } from "~/hooks/useImageUpload";

export function BlogPostEditor() {
  const [content, setContent] = useState("");
  const { handleImageUpload } = useImageUpload({ context: "post" });

  return (
    <RichEditor
      content={content}
      onChange={setContent}
      onImageUpload={handleImageUpload}
      placeholder="Write your post... Paste or drag images!"
    />
  );
}

With Form Submission

Handle attachments in form submissions:

import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";
import { trpc } from "~/lib/trpc/client";

export function IssueForm() {
  const [attachments, setAttachments] = useState<string[]>([]);
  const { handleFileUpload, uploading } = useAttachmentUpload();
  const createIssue = trpc.issues.create.useMutation();

  const onSubmit = async (formData: { title: string; description: string }) => {
    await createIssue.mutateAsync({
      ...formData,
      attachmentUrls: attachments, // Include uploaded file URLs
    });
  };

  const onFilesAdded = async (files: FileList) => {
    const fileParts = Array.from(files).map(file => ({
      type: "file" as const,
      filename: file.name,
      mimeType: file.type,
      url: URL.createObjectURL(file),
    }));

    const uploaded = await handleFileUpload(fileParts);
    setAttachments(prev => [...prev, ...uploaded.map(f => f.url)]);
  };

  return (
    <form onSubmit={onSubmit}>
      {/* Form fields */}
      <input type="file" multiple onChange={(e) => e.target.files && onFilesAdded(e.target.files)} />
      {uploading && <p>Uploading...</p>}
      <button type="submit" disabled={uploading}>Submit</button>
    </form>
  );
}

Troubleshooting

Upload fails with 403 Forbidden

Cause: S3 credentials are invalid or bucket policy is incorrect.

Solution:

  1. Verify AWS credentials in .env
  2. Check S3 bucket CORS configuration:
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag"]
  }
]

Upload fails with "File size exceeds limit"

Cause: File is larger than 10MB.

Solution:

  1. Compress the file
  2. Or increase MAX_FILE_SIZE in src/server/routers/attachments.ts

Upload succeeds but public URL returns 404

Cause: File was uploaded but presigned URL expired.

Solution: Call attachments.confirmUpload to get a fresh presigned URL.

MIME type not allowed

Cause: File type is not in the allowed list.

Solution: Add MIME type to allowedMimeTypes in src/server/routers/attachments.ts.


Best Practices

  1. Always validate on server-side - Never trust client-side validation alone
  2. Use context for organization - Use "post" for blog posts, "general" for other files
  3. Set reasonable file size limits - Default 10MB is good for images; adjust based on use case
  4. Handle errors gracefully - Show user-friendly error messages
  5. Clean up blob URLs - Call URL.revokeObjectURL() after upload to free memory
  6. Use presigned URLs wisely - They expire; regenerate if needed for long-term storage
  7. Monitor S3 costs - Track uploads and storage usage in production


Next Steps

Set up S3 credentials

Add AWS credentials to your .env file and configure S3 bucket CORS.

Try the upload hooks

Use useImageUpload or useAttachmentUpload in your components.

Customize validation

Adjust file size limits and allowed MIME types based on your needs.

Implement progress tracking

Add upload progress indicators for better UX.


You're ready! The upload system is production-ready with security, validation, and scalability built-in.

On this page