[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
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

Skills stack · 6 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
>

s3-storage

v1.0.0

Manages S3-compatible object storage (AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, Wasabi, Supabase Storage). Use when the user wants to create buckets, upload/download files, set up lifecycle policies, configure CORS, manage presigned URLs, implement multipart uploads, set up replication, handle versioning, configure access policies, or build file management features on top of S3-compatible APIs. Trigger words: s3, minio, r2, object storage, bucket, presigned url, multipart upload, lifecycle policy, s3 cors, storage backend, file storage, blob storage, spaces, backblaze, wasabi.

93/100 quality
1.96× impact
SAFE
View skill
$

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