General
Storage

Access Files

Learn how to access and download files stored in your storage provider.

Files uploaded to your storage provider are not publicly accessible by default. This guide shows you how to generate signed URLs to access files securely.

Overview

To access stored files, you need to:

  1. Generate a signed URL on the server (with access control)
  2. Use the signed URL to access the file
  3. The URL expires after a set time for security

Generating Signed URLs

Server-Side (tRPC)

Create a procedure to generate signed download URLs:

trpc/routers/upload/index.ts
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';

import { storageConfig } from '@/config/storage.config';
import { storageService } from '@/lib/storage';

export const uploadRouter = createTRPCRouter({
  getDownloadUrl: protectedProcedure
    .input(
      z.object({
        path: z.string().min(1),
        expiresIn: z.number().default(3600) // Default 1 hour
      })
    )
    .query(async ({ input, ctx }) => {
      // Verify user has access to this file
      // (e.g., check if file belongs to user or organization)
      const hasAccess = await verifyFileAccess(input.path, ctx.user.id);

      if (!hasAccess) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: "You don't have access to this file"
        });
      }

      const signedUrl = await storageService.getSignedUrl(
        input.path,
        storageConfig.bucketNames.images,
        input.expiresIn
      );

      return { signedUrl };
    })
});

Access Control

Always verify access before generating signed URLs:

lib/storage/access-control.ts
import { eq } from 'drizzle-orm';

import { db } from '@/lib/db';
import { filesTable } from '@/lib/db/schema';

export async function verifyFileAccess(
  filePath: string,
  userId: string
): Promise<boolean> {
  // Check if file exists and belongs to user
  const file = await db.query.filesTable.findFirst({
    where: (files, { eq, and }) =>
      and(eq(files.path, filePath), eq(files.userId, userId))
  });

  return !!file;
}

// For organization files
export async function verifyOrganizationFileAccess(
  filePath: string,
  organizationId: string,
  userId: string
): Promise<boolean> {
  // Check if user is member of organization
  const membership = await db.query.memberTable.findFirst({
    where: (members, { eq, and }) =>
      and(
        eq(members.organizationId, organizationId),
        eq(members.userId, userId)
      )
  });

  if (!membership) return false;

  // Check if file belongs to organization
  const file = await db.query.filesTable.findFirst({
    where: (files, { eq, and }) =>
      and(eq(files.path, filePath), eq(files.organizationId, organizationId))
  });

  return !!file;
}

Client-Side Access

Using the Hook

The useStorage hook automatically generates signed URLs:

components/file-display.tsx
'use client';

import { useStorage } from '@/hooks/use-storage';

export function FileDisplay({ filePath }: { filePath: string }) {
  const url = useStorage(filePath);

  return (
    <img
      src={url}
      alt="File"
    />
  );
}

Manual Access

For more control, fetch the signed URL manually:

components/file-download.tsx
'use client';

import { useState } from 'react';
import { trpc } from '@/trpc/client';

export function FileDownload({ filePath }: { filePath: string }) {
  const [downloading, setDownloading] = useState(false);
  // Note: In the actual implementation, useStorage hook uses a proxy route
  // For manual access, you would need to add a getDownloadUrl endpoint to uploadRouter
  // or use the useStorage hook which handles this automatically
  const { data } = trpc.upload.getDownloadUrl?.useQuery({
    path: filePath,
    expiresIn: 60 // 1 minute
  });

  const handleDownload = async () => {
    if (!data?.signedUrl) return;

    setDownloading(true);
    try {
      const response = await fetch(data.signedUrl);
      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filePath.split('/').pop() || 'file';
      document.body.appendChild(a);
      a.click();
      window.URL.revokeObjectURL(url);
      document.body.removeChild(a);
    } finally {
      setDownloading(false);
    }
  };

  return (
    <button
      onClick={handleDownload}
      disabled={downloading || !data}
    >
      {downloading ? 'Downloading...' : 'Download'}
    </button>
  );
}

Creating a File Proxy

For frequently accessed files, create a proxy endpoint that automatically generates signed URLs:

app/api/storage/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';

import { storageConfig } from '@/config/storage.config';
import { getSession } from '@/lib/auth/server';
import { storageService } from '@/lib/storage';
import { verifyFileAccess } from '@/lib/storage/access-control';

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const session = await getSession();

  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { path: pathSegments } = await params;
  const [bucket, ...filePath] = pathSegments;

  if (!bucket || !filePath.length) {
    return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
  }

  // Only allow access to specific buckets
  if (bucket !== storageConfig.bucketNames.images) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const fullPath = filePath.join('/');

  // Verify access
  const hasAccess = await verifyFileAccess(fullPath, session.user.id);
  if (!hasAccess) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  // Generate signed URL
  const signedUrl = await storageService.getSignedUrl(
    fullPath,
    bucket,
    3600 // 1 hour
  );

  // Redirect to signed URL
  return NextResponse.redirect(signedUrl, {
    headers: {
      'Cache-Control': 'public, max-age=3600'
    }
  });
}

Usage:

components/image.tsx
import { useStorage } from '@/hooks/use-storage';

export function Image({ filePath }: { filePath: string }) {
  // useStorage hook automatically handles the proxy route
  const url = useStorage(filePath);

  return (
    <img
      src={url}
      alt="Image"
    />
  );
}

Listing Files

To list files in a directory:

trpc/routers/upload/index.ts
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
import { z } from 'zod';
import { storageConfig } from '@/config/storage.config';
import { storageService } from '@/lib/storage';
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';

// Add to uploadRouter
listFiles: protectedProcedure
  .input(
    z.object({
      prefix: z.string().optional(), // e.g., "users/user-id/"
    })
  )
  .query(async ({ input, ctx }) => {
    // Use S3 client to list objects
    const s3 = storageService.getS3Client();
    const command = new ListObjectsV2Command({
      Bucket: storageConfig.bucketNames.images,
      Prefix: input.prefix || `users/${ctx.user.id}/`,
    });

    const response = await s3.send(command);
    return response.Contents || [];
  }),

Deleting Files

trpc/routers/upload/index.ts
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { TRPCError } from '@trpc/server';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { storageConfig } from '@/config/storage.config';
import { db } from '@/lib/db';
import { filesTable } from '@/lib/db/schema';
import { storageService } from '@/lib/storage';
import { verifyFileAccess } from '@/lib/storage/access-control';
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';

// Add to uploadRouter
deleteFile: protectedProcedure
  .input(z.object({ path: z.string() }))
  .mutation(async ({ input, ctx }) => {
    // Verify access
    const hasAccess = await verifyFileAccess(input.path, ctx.user.id);
    if (!hasAccess) {
      throw new TRPCError({ code: "FORBIDDEN" });
    }

    // Delete from storage
    const s3 = storageService.getS3Client();
    await s3.send(
      new DeleteObjectCommand({
        Bucket: storageConfig.bucketNames.images,
        Key: input.path,
      })
    );

    // Delete from database
    await db.delete(filesTable).where(eq(filesTable.path, input.path));
  }),

Security Best Practices

  1. Always verify access - Check permissions before generating signed URLs
  2. Short expiration times - Use short expiration for sensitive files (60 seconds)
  3. Longer expiration for public - Use longer expiration for less sensitive files (1 hour)
  4. Cache signed URLs - Cache generated URLs to reduce API calls
  5. Monitor access - Log file access for auditing
  6. Rate limiting - Limit how many signed URLs can be generated per user