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