Skip to content

Uploads Module - Getting Started ​

Glasswork's uploads module provides secure S3 presigned URL file uploads optimized for serverless environments. Files are uploaded directly from the frontend to S3, bypassing Lambda body size limits.

After reading this guide, you will know:

  • How to configure the uploads service with AWS S3
  • How to create secure upload paths with random IDs
  • How to validate file names and extensions
  • How to type responses and stream handlers correctly
  • How to serve private files via presigned download URLs

Quick Start ​

1. Install Dependencies ​

bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
bash
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

2. Create the Uploads Module ​

typescript
// src/modules/uploads/uploads.module.ts
import { defineModule } from 'glasswork/core';
import { UploadsService } from 'glasswork/uploads';
import { uploadsRoutes } from './uploads.routes';

export const UploadsModule = defineModule({
  name: 'uploads',
  providers: [
    {
      provide: 'uploadsService',
      useFactory: ({ config }) => new UploadsService({
        region: config.get('awsRegion'),
        bucketName: config.get('s3FilesBucket'),
        urlExpiration: config.get('s3UrlExpiration'),
      }),
    },
  ],
  routes: uploadsRoutes,
  exports: ['uploadsService'],
});

3. Create Upload Routes ​

typescript
// src/modules/uploads/uploads.routes.ts
import { createRoutes } from 'glasswork/http';
import { sanitizedFileName, createUploadConfig, assertUploadPathOwnership } from 'glasswork/uploads';
import * as v from 'valibot';

// Define allowed extensions for your app
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const;

// ============================================================================
// Request DTOs
// ============================================================================

const GetUploadUrlQueryDto = v.object({
  name: sanitizedFileName(ALLOWED_IMAGE_EXTENSIONS),
});

// ============================================================================
// Response DTOs
// ============================================================================

const UploadUrlResponseDto = v.object({
  uploadUrl: v.string(),
  path: v.string(),
  expiresAt: v.string(),
});

export const uploadsRoutes = createRoutes((router, { uploadsService }, route) => {
  // Get presigned URL for upload
  router.get('/api/me/avatar-upload-url', ...route({
    summary: 'Get presigned URL for avatar upload',
    query: GetUploadUrlQueryDto,
    responses: { 200: UploadUrlResponseDto },
    handler: async ({ query, user }) => {
      const config = createUploadConfig('avatars', [user.id], query.name);
      const result = await uploadsService.getSignedUploadUrl(config);
      return {
        uploadUrl: result.uploadUrl,
        path: result.path,
        expiresAt: result.expiresAt.toISOString(),
      };
    },
  }));

  // Delete uploaded file
  router.delete('/api/me/avatar', ...route({
    summary: 'Delete avatar',
    query: v.object({ path: v.string() }),
    responses: { 204: v.null_() },
    handler: async ({ query, user }) => {
      assertUploadPathOwnership(query.path, 'avatars', [user.id]);
      await uploadsService.deleteFile(query.path);
      return null;
    },
  }));
});

File Name Validation ​

Use sanitizedFileName to validate extensions and create URL-safe filenames:

typescript
import { sanitizedFileName } from 'glasswork/uploads';

// Define allowed extensions per use case
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const;
const ALLOWED_DOCUMENT_EXTENSIONS = ['.pdf', '.docx'] as const;

// Create validators
const imageNameSchema = sanitizedFileName(ALLOWED_IMAGE_EXTENSIONS);
const documentNameSchema = sanitizedFileName(ALLOWED_DOCUMENT_EXTENSIONS);

// Input: "My Photo (1).PNG" → Output: "my-photo-1.png"

If you want to validate without sanitizing, use fileNameWithExtension:

typescript
import { fileNameWithExtension } from 'glasswork/uploads';

const schema = fileNameWithExtension(['.pdf', '.docx']);
// Validates extension but keeps original filename

Upload Path Security ​

Path Structure ​

Files are stored with this structure:

/uploads/{prefix}/{ids...}/{randomId}/{filename}
  • prefix: Category like avatars, documents, logos
  • ids: Ownership identifiers like [userId] or [orgId, projectId]
  • randomId: 16-char hex string prevents overwriting
  • filename: Sanitized filename

Ownership Validation ​

Use assertUploadPathOwnership before delete operations:

typescript
import { assertUploadPathOwnership } from 'glasswork/uploads';

// In delete handler - throws ForbiddenException if path doesn't match
assertUploadPathOwnership(
  query.path,        // Path from request
  'avatars',         // Expected prefix
  [user.id]          // Expected IDs
);

This prevents:

  • Path traversal attacks (../admin/file.txt)
  • Accessing other users' files
  • URL-encoded attacks (%2e%2e)

File Access Methods ​

Glasswork supports three methods for serving files, each with different security and performance characteristics:

MethodAuthenticationFile SizeUse Case
CDN (CloudFront)NoneUnlimitedPublic assets, profile images
Presigned URLsAt URL generationUnlimitedPrivate files, large downloads
Backend StreamingPer-request< 6MBStrictly authenticated small files

Method 1: CDN Access (Public Files) ​

Files served directly through CloudFront provide the best performance but minimal security. The URL includes a unique random segment that's hard to guess, but anyone with the URL can access the file.

Best for: Profile pictures, organization logos, public attachments

typescript
// Store the path, construct CDN URL on frontend
const cdnUrl = `${import.meta.env.VITE_CDN_URL}${user.imagePath}`;
// https://cdn.example.com/uploads/avatars/user123/a1b2c3d4/photo.jpg
ProsCons
✅ Fastest delivery (edge cached)❌ No access control after upload
✅ No Lambda invocation❌ URL can be shared by anyone
✅ Unlimited file size❌ Requires CloudFront + S3 setup

Method 2: Presigned Download URLs (Private Files) ​

Generate temporary S3 URLs that expire after a configurable period. Authentication happens when requesting the URL, not when downloading.

Best for: Private documents, file exports, large media files

typescript
const DownloadUrlResponseDto = v.object({
  downloadUrl: v.string(),
  expiresAt: v.string(),
});

router.get('/api/documents/:id/download-url', ...route({
  params: v.object({ id: v.string() }),
  responses: { 200: DownloadUrlResponseDto },
  handler: async ({ params, user }) => {
    const doc = await documentService.getById(params.id);

    if (doc.ownerId !== user.id) {
      throw new ForbiddenException('Access denied');
    }

    // Generate presigned GET URL (default: 1 hour expiration)
    const result = await uploadsService.getSignedDownloadUrl(doc.filePath);

    // Or with short expiration (5 minutes) for sensitive files
    // const result = await uploadsService.getSignedDownloadUrl(doc.filePath, 300);

    return {
      downloadUrl: result.downloadUrl,
      expiresAt: result.expiresAt.toISOString(),
    };
  },
}));
ProsCons
✅ Works for any file size⚠️ URL can be shared during expiry
✅ Fast (direct S3 download)⚠️ Auth only at URL generation
✅ No Lambda payload limits❌ Extra API call to get URL

TIP

Use short expiration times (60-300 seconds) for sensitive files. The client requests the URL just before downloading.

Method 3: Backend Streaming (Strictly Authenticated) ​

Stream files through Lambda for real-time authentication on every request. The user's session is validated before any file data is sent.

Best for: Highly sensitive files requiring strict access control

typescript
router.get('/api/documents/:id/content', ...route({
  params: v.object({ id: v.string() }),
  // Note: No responses key when returning raw Response
  handler: async ({ params, user, context }): Promise<Response> => {
    const doc = await documentService.getById(params.id);

    if (doc.ownerId !== user.id) {
      throw new ForbiddenException('Access denied');
    }

    const { body, contentType, contentLength } =
      await uploadsService.streamFile(doc.filePath);

    // Return raw Response - bypasses Glasswork's response handling
    return new Response(body as unknown as ReadableStream, {
      headers: {
        'Content-Type': contentType ?? 'application/octet-stream',
        'Content-Length': String(contentLength ?? 0),
        'Content-Disposition': `attachment; filename="${doc.fileName}"`,
      },
    });
  },
}));
ProsCons
✅ Auth on every request❌ 6MB limit (Lambda payload)
✅ Cannot share URL❌ Slower (Lambda + S3 roundtrip)
✅ Audit logging possible❌ Higher Lambda costs

WARNING

Lambda has a 6MB response payload limit for synchronous invocations. For files that may exceed this, use presigned download URLs instead.

Environment Variables ​

env
# Required
AWS_REGION=eu-west-1
S3_FILES_BUCKET=my-app-files

# Optional
S3_URL_EXPIRATION=3600  # Presigned URL expiration in seconds (default: 3600)

API Reference ​

UploadsService ​

MethodDescription
getSignedUploadUrl(config)Generate presigned PUT URL for uploads
getSignedDownloadUrl(path, expiresIn?)Generate presigned GET URL for downloads
streamFile(path)Stream file through Lambda (< 6MB)
deleteFile(path)Delete file from S3

Utilities ​

FunctionDescription
createUploadConfig(prefix, ids, filename)Generate secure upload path
assertUploadPathOwnership(path, prefix, ids)Validate file ownership
sanitizedFileName(extensions)Valibot schema for sanitized filenames
fileNameWithExtension(extensions)Valibot schema for filename validation

Testing ​

Create a mock UploadsService for testing:

typescript
import { vi } from 'vitest';

const mockUploadsService = {
  getSignedUploadUrl: vi.fn().mockResolvedValue({
    uploadUrl: 'https://bucket.s3.amazonaws.com/presigned',
    path: '/uploads/avatars/user123/abc123/photo.jpg',
    expiresAt: new Date('2026-01-20'),
  }),
  getSignedDownloadUrl: vi.fn().mockResolvedValue({
    downloadUrl: 'https://bucket.s3.amazonaws.com/presigned-get',
    expiresAt: new Date('2026-01-20'),
  }),
  deleteFile: vi.fn().mockResolvedValue(undefined),
  streamFile: vi.fn().mockResolvedValue({
    body: new ReadableStream(),
    contentType: 'application/pdf',
    contentLength: 12345,
  }),
};

Inspecting and Finalizing Uploads ​

When users upload to a temporary path, check the file before moving it to its final location:

  1. Call inspectFile(stagingKey) to read S3's file metadata, such as size, content type, and ETag.
  2. Check that metadata against your application's upload rules.
  3. Pass the returned ETag to finalizeUpload(stagingKey, finalKey, etag).

For example, after checking file ownership and the allowed size, you can require a PDF and finalize it:

typescript
const metadata = await uploadsService.inspectFile(stagingKey);
if (metadata.ContentType !== 'application/pdf' || !metadata.ETag) {
  throw new Error('Expected a PDF upload with an ETag');
}
await uploadsService.finalizeUpload(stagingKey, finalKey, metadata.ETag);

Glasswork copies the file only if its ETag still matches, using S3's CopySourceIfMatch option. It deletes the temporary file after the copy succeeds. If the file changed after inspection, the copy fails with the original AWS error and the temporary file is kept.

The copy, deletion, and your database writes are separate operations. If cleanup or a database write fails, your application decides whether to retry cleanup or remove the copied file. File types, sizes, and tenant paths also remain your application's responsibility.

Custom S3 Options ​

You can pass AWS SDK options when generating signed URLs:

typescript
await uploadsService.getSignedUploadUrl(file, {
  expiresIn: 300,
  command: { ContentType: 'application/pdf' },
});
await uploadsService.getSignedDownloadUrl(finalKey, 300, {
  ResponseContentDisposition: 'attachment',
});

The service still controls the bucket and key. To use your own S3 configuration, pass an S3Client through the constructor's client option. It is also available as uploadsService.client for additional AWS commands. inspectFile() returns the AWS SDK's HeadObject response without changing its fields.

Released under the MIT License.