Terminal.skills
Use Cases/Build a File Manager with S3

Build a File Manager with S3

Build a file management system with folders, presigned uploads, image thumbnails, sharing links, storage quotas, and trash/restore — a Dropbox-style file manager backed by S3-compatible storage.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Dani leads engineering at a 25-person company. Files are scattered across Google Drive, Slack attachments, email threads, and local machines. Employees can't find the latest version of documents. Client files have no access control — anyone with the link can see everything. Large file uploads fail because the API has a 10MB body limit. They're paying $500/month for a file management SaaS that doesn't integrate with their app. They need an in-app file manager with folders, secure uploads, access control, and preview.

Step 1: Build the File Manager Backend

typescript
// src/files/manager.ts — File manager with S3, folders, sharing, and quotas
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, CopyObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { pool } from "../db";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

const s3 = new S3Client({
  region: process.env.S3_REGION!,
  endpoint: process.env.S3_ENDPOINT,     // for MinIO/R2 compatibility
  forcePathStyle: true,
});
const BUCKET = process.env.S3_BUCKET!;
const MAX_FILE_SIZE = 500 * 1024 * 1024;  // 500MB
const QUOTA_BYTES = 10 * 1024 * 1024 * 1024; // 10GB per workspace

interface FileRecord {
  id: string;
  name: string;
  mimeType: string;
  size: number;
  s3Key: string;
  folderId: string | null;
  workspaceId: string;
  uploadedBy: string;
  thumbnailKey: string | null;
  isDeleted: boolean;
  deletedAt: string | null;
  createdAt: string;
}

interface Folder {
  id: string;
  name: string;
  parentId: string | null;
  workspaceId: string;
  path: string;               // materialized path: "/docs/contracts/"
}

// Generate presigned upload URL (client uploads directly to S3)
export async function createUploadUrl(
  workspaceId: string,
  userId: string,
  fileName: string,
  mimeType: string,
  fileSize: number,
  folderId?: string
): Promise<{ uploadUrl: string; fileId: string; s3Key: string }> {
  // Check quota
  const usage = await getStorageUsage(workspaceId);
  if (usage + fileSize > QUOTA_BYTES) {
    const usedGB = (usage / (1024 ** 3)).toFixed(1);
    const limitGB = (QUOTA_BYTES / (1024 ** 3)).toFixed(0);
    throw new Error(`Storage quota exceeded: ${usedGB}GB of ${limitGB}GB used`);
  }

  if (fileSize > MAX_FILE_SIZE) {
    throw new Error(`File too large. Maximum: ${MAX_FILE_SIZE / (1024 * 1024)}MB`);
  }

  const fileId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  const ext = fileName.split(".").pop() || "";
  const s3Key = `${workspaceId}/${fileId}.${ext}`;

  // Create presigned PUT URL (expires in 15 min)
  const command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: s3Key,
    ContentType: mimeType,
    ContentLength: fileSize,
  });
  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 900 });

  // Record file metadata (status: uploading)
  await pool.query(
    `INSERT INTO files (id, name, mime_type, size, s3_key, folder_id, workspace_id, uploaded_by, status, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'uploading', NOW())`,
    [fileId, fileName, mimeType, fileSize, s3Key, folderId || null, workspaceId, userId]
  );

  return { uploadUrl, fileId, s3Key };
}

// Confirm upload completed (called after client finishes S3 upload)
export async function confirmUpload(fileId: string): Promise<FileRecord> {
  await pool.query("UPDATE files SET status = 'active' WHERE id = $1", [fileId]);

  // Generate thumbnail for images
  const { rows: [file] } = await pool.query("SELECT * FROM files WHERE id = $1", [fileId]);
  if (file.mime_type.startsWith("image/")) {
    await redis.rpush("thumbnail:queue", JSON.stringify({ fileId, s3Key: file.s3_key }));
  }

  // Update storage usage cache
  await redis.del(`storage:usage:${file.workspace_id}`);

  return file;
}

// Generate presigned download URL
export async function getDownloadUrl(fileId: string, userId: string): Promise<string> {
  const { rows: [file] } = await pool.query(
    "SELECT s3_key, name, workspace_id FROM files WHERE id = $1 AND status = 'active'",
    [fileId]
  );
  if (!file) throw new Error("File not found");

  // Check access
  await verifyAccess(file.workspace_id, userId);

  const command = new GetObjectCommand({
    Bucket: BUCKET,
    Key: file.s3_key,
    ResponseContentDisposition: `attachment; filename="${file.name}"`,
  });

  return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hour
}

// List files in folder
export async function listFiles(
  workspaceId: string,
  folderId: string | null,
  options?: { sort?: "name" | "date" | "size"; order?: "asc" | "desc" }
): Promise<{ folders: Folder[]; files: FileRecord[] }> {
  const sortCol = options?.sort === "name" ? "name" : options?.sort === "size" ? "size" : "created_at";
  const sortDir = options?.order === "asc" ? "ASC" : "DESC";

  const [folders, files] = await Promise.all([
    pool.query(
      `SELECT * FROM folders WHERE workspace_id = $1 AND parent_id ${folderId ? `= $2` : "IS NULL"} ORDER BY name`,
      folderId ? [workspaceId, folderId] : [workspaceId]
    ),
    pool.query(
      `SELECT * FROM files WHERE workspace_id = $1 AND folder_id ${folderId ? `= $2` : "IS NULL"} AND status = 'active'
       ORDER BY ${sortCol} ${sortDir}`,
      folderId ? [workspaceId, folderId] : [workspaceId]
    ),
  ]);

  return { folders: folders.rows, files: files.rows };
}

// Create share link (public or password-protected)
export async function createShareLink(
  fileId: string,
  userId: string,
  options?: { password?: string; expiresInHours?: number; maxDownloads?: number }
): Promise<{ shareUrl: string; shareId: string }> {
  const shareId = `share-${Math.random().toString(36).slice(2, 10)}`;

  await pool.query(
    `INSERT INTO file_shares (id, file_id, created_by, password_hash, expires_at, max_downloads, download_count, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, 0, NOW())`,
    [shareId, fileId, userId,
     options?.password ? hashPassword(options.password) : null,
     options?.expiresInHours ? new Date(Date.now() + options.expiresInHours * 3600000) : null,
     options?.maxDownloads || null]
  );

  return {
    shareId,
    shareUrl: `${process.env.APP_URL}/files/shared/${shareId}`,
  };
}

// Move to trash (soft delete with 30-day retention)
export async function moveToTrash(fileId: string, userId: string): Promise<void> {
  await pool.query(
    "UPDATE files SET status = 'trashed', deleted_at = NOW(), deleted_by = $2 WHERE id = $1",
    [fileId, userId]
  );
  const { rows: [file] } = await pool.query("SELECT workspace_id FROM files WHERE id = $1", [fileId]);
  await redis.del(`storage:usage:${file.workspace_id}`);
}

// Restore from trash
export async function restoreFromTrash(fileId: string): Promise<void> {
  await pool.query(
    "UPDATE files SET status = 'active', deleted_at = NULL, deleted_by = NULL WHERE id = $1 AND status = 'trashed'",
    [fileId]
  );
}

// Permanently delete trashed files older than 30 days
export async function cleanupTrash(): Promise<number> {
  const { rows: files } = await pool.query(
    "SELECT id, s3_key FROM files WHERE status = 'trashed' AND deleted_at < NOW() - interval '30 days'"
  );

  for (const file of files) {
    await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: file.s3_key }));
    await pool.query("DELETE FROM files WHERE id = $1", [file.id]);
  }

  return files.length;
}

async function getStorageUsage(workspaceId: string): Promise<number> {
  const cached = await redis.get(`storage:usage:${workspaceId}`);
  if (cached) return parseInt(cached);

  const { rows: [{ total }] } = await pool.query(
    "SELECT COALESCE(SUM(size), 0) as total FROM files WHERE workspace_id = $1 AND status = 'active'",
    [workspaceId]
  );

  const usage = parseInt(total);
  await redis.setex(`storage:usage:${workspaceId}`, 3600, String(usage));
  return usage;
}

async function verifyAccess(workspaceId: string, userId: string) {
  const { rows } = await pool.query(
    "SELECT 1 FROM workspace_members WHERE workspace_id = $1 AND user_id = $2",
    [workspaceId, userId]
  );
  if (rows.length === 0) throw new Error("Access denied");
}

function hashPassword(pw: string): string { return pw; /* bcrypt in production */ }

Results

  • 500MB file uploads work reliably — presigned URLs let clients upload directly to S3; no server memory or body size limits; upload progress bar works natively
  • Storage costs: $500/month → $23/month — S3 Standard costs $0.023/GB; 1TB of files costs $23; even with R2 it's $15/month
  • Access control enforced — workspace membership checked on every file access; shared links can be password-protected and expire automatically
  • Trash protects against mistakes — deleted files sit in trash for 30 days; "I accidentally deleted the contract" is a 1-click restore instead of a backup recovery
  • Storage quotas prevent abuse — 10GB per workspace with clear usage reporting; users upgrade before they hit limits