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:
- Generate a signed URL on the server (with access control)
- Use the signed URL to access the file
- 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
- Always verify access - Check permissions before generating signed URLs
- Short expiration times - Use short expiration for sensitive files (60 seconds)
- Longer expiration for public - Use longer expiration for less sensitive files (1 hour)
- Cache signed URLs - Cache generated URLs to reduce API calls
- Monitor access - Log file access for auditing
- Rate limiting - Limit how many signed URLs can be generated per user