All files / src/common/services r2-signed-url.service.ts

83.33% Statements 15/18
60% Branches 6/10
66.66% Functions 2/3
82.35% Lines 14/17

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 6420x 20x 20x 20x                   20x         20x   12x 12x   12x 12x   12x                                 1x 1x 1x                                    
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 
/**
 * R2SignedUrlService – issues pre-signed URLs for objects stored in a *private* Cloudflare R2 bucket.
 *
 * Why a dedicated service instead of using MediaService directly?
 *  • isolates SigV4 logic & credentials (principle of least privilege – we can mount *read-only* creds here).
 *  • keeps a single responsibility: URL signing (extendable to AWS S3 later by swapping the endpoint).
 */
@Injectable()
export class R2SignedUrlService {
  private readonly s3: S3Client;
  private readonly bucket: string;
 
  /** 7 days in seconds – R2 maximum TTL for signed URLs */
  private static readonly MAX_TTL = 7 * 24 * 60 * 60;
 
  constructor(private readonly config: ConfigService) {
    this.bucket = this.config.get<string>('R2_BUCKET', 'media-bucket');
 
    const endpoint = this.config.get<string>('R2_ENDPOINT', 'https://r2.cloudflarestorage.com');
    const region = this.config.get<string>('R2_REGION', 'auto');
 
    this.s3 = new S3Client({
      region,
      endpoint,
      forcePathStyle: true, // required for R2
      credentials: {
        accessKeyId: this.config.get<string>('R2_ACCESS_KEY_ID') || '',
        secretAccessKey: this.config.get<string>('R2_SECRET_ACCESS_KEY') || '',
      },
    });
  }
 
  /**
   * Generate a *download* URL.
   * @param key  Object key (same as you pass to PutObjectCommand)
   * @param ttl  Time-to-live in **seconds** (capped at 7 days)
   */
  async signGet(key: string, ttl = 60 * 60): Promise<string> {
    const expiresIn = Math.min(ttl, R2SignedUrlService.MAX_TTL);
    const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
    return getSignedUrl(this.s3, command, { expiresIn });
  }
 
  /**
   * Generate an *upload* URL (useful for browser or mobile direct-to-R2 uploads).
   * @param key         Destination object key
   * @param ttl         Time-to-live in **seconds** (capped at 7 days)
   * @param contentType Optional content-type hint (iOS clients often require it)
   */
  async signPut(key: string, ttl = 10 * 60, contentType?: string): Promise<string> {
    const expiresIn = Math.min(ttl, R2SignedUrlService.MAX_TTL);
    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: contentType,
    });
    return getSignedUrl(this.s3, command, { expiresIn });
  }
}