Skip to content

Download & Export ​

Overview ​

Collage Admin supports three download paths, each suited to a different use case:

  1. Single-asset proxied download — the Nitro server fetches the S3 URL and streams it back with a correct Content-Disposition header, keeping the S3 bucket URL private from the browser.
  2. Presigned URL download — server/api/s3/get-signed-url.get.ts generates a short-lived (24-hour) S3 presigned URL that the frontend uses directly. Requires a valid Bearer token.
  3. Bulk zip download — multiple assets are assembled into a ZIP archive in the browser using the StreamSaver library, streamed directly to the filesystem via a write-stream with no server-side zip step and no memory limit.

Format and resolution conversion are available at download time as a plan-gated feature. The FileConversionDialog presents format and resolution selectors; on confirm the Nitro resolution.post.ts route resizes and re-encodes the image using Jimp and returns the transformed buffer.

Architecture ​

StreamSaver is registered as a client-only Nuxt plugin (stream-saver.client.ts). It is exposed on nuxtApp as $streamSaver and proxied so that calling createWriteStream on auth pages throws rather than silently failing. The actual zip-stream work is driven by the DAM store's downloadMultipleFiles action, which calls useZipDownloadApi.fetchZipStream() to POST the asset list to an external zip-worker microservice (ZIP_DOWNLOAD_URL runtime config), then pipes the response body into a StreamSaver write-stream.

Image conversion runs entirely server-side in the Nitro process. The resolution.post.ts handler fetches the source image itself (with a 30-second timeout and a 200 MB size cap), reads PNG/GIF/JPEG headers to reject oversized sources before decoding, uses Jimp to resize and re-encode, and streams the output buffer back. Callers on the frontend side go through useFileConversionDialog, which computes the available format options and resolution list from the asset detail and subscription plan.

File Structure ​

Plugin Files ​

  • app/plugins/stream-saver.client.ts — StreamSaver plugin
    • Client-only; skipped during SSR
    • Provides $streamSaver on nuxtApp
    • Proxies createWriteStream to block calls on auth pages (index, forgot-password, reset-password, social-login, generate-password)
    • Reads route.name reactively at call time to avoid freezing the auth-page decision at plugin boot

Composable Files ​

  • app/composables/api/useZipDownloadApi.ts — zip download API composable

    • fetchZipStream(payload) — POSTs to external zip-worker, returns raw streaming Response
    • Uses native fetch (not $api) intentionally — callers need the raw body for streaming
    • Fails loud when ZIP_DOWNLOAD_URL is not configured
    • Request state tracked via useApiRequestState
  • app/composables/core/dialogs/useFileConversionDialog.ts — conversion dialog composable

    • Derives downloadableFormatOptions from file.mime_type and plan entitlements
    • Derives otherResolutions from asset dimensions
    • Computes selectedFormat, selectedResolution, actionDisabled, btnTxt
    • Exposes handleAction, closeDialog

Component Files ​

  • app/components/dialogs/FileConversionDialog.vue — format and resolution picker dialog
    • Format selector (v-select) — disabled and tooltipped for non-Pro plans
    • Resolution selector (v-select) — disabled when mime type is not resizable
    • Download / Convert button with loading state
    • Emits download-org, convert-resolution, convert-format depending on selection

Server Route Files ​

  • server/api/s3/download-file.get.ts — proxied single-file download

    • Query params: url (encoded S3/presigned URL), name (base filename)
    • Fetches source URL server-side, detects content-type, maps to extension
    • Sets Content-Disposition: attachment and streams the body back to the browser
    • Supported extensions: png, jpg, webp, gif, svg, pdf, txt, json, zip
  • server/api/s3/get-signed-url.get.ts — S3 presigned URL generator

    • Query params: assetId, workspaceId
    • Requires Bearer token; validates workspaceId format and assetId against [A-Za-z0-9_-]+
    • Generates {workspaceId}/digital_assets/{assetId} key
    • Presigned URL expires in 86400 seconds (24 hours)
  • server/api/resolution.post.ts — image resize and format conversion

    • Request body: { w, h, s, q, m? } (width, height, source URL or base64, quality, target mime)
    • Fetches source with 30-second timeout and 200 MB streaming cap
    • Reads PNG/GIF/JPEG headers to reject images over 600 megapixels before decoding
    • Resizes with Jimp (image.resize({ w, h })), encodes with per-format quality
    • Supported output: image/jpeg, image/png, image/bmp, image/tiff, image/gif
    • Returns raw image buffer with correct Content-Type header

Page Files ​

  • app/pages/[workspace_id]/dam/sharing/index.vue — share and embed management page; two tabs (Shares / Embeds), filter controls, bulk revoke/delete, and per-row action menus

Page Details ​

Sharing ([workspace_id]/dam/sharing/index.vue) ​

FieldValue
Route/:workspace_id/dam/sharing
Layoutcollage-layout
Middlewareauth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended
Primary composableuseSharing()

Tabs

TabColumnsNotes
SharesTitle, Description, Created Date, 30-day views, Created ByFilter v-select: active / revoked / expired
EmbedsFilename, Embed URL, Date, Created ByNo status filter

Bulk-action bar (DamBulkActionsBar) — hidden for viewer-role users

ActionGate
Revokenon-viewer only
Deletenon-viewer only

Marquee drag-selection is also disabled for viewers. Per-row context menus differ between active shares (Copy Link, Edit, Revoke, Delete) and revoked/expired shares (Copy Link, Delete).

Dialogs: ConfirmationDialog, AdvanceShareDialog


FileConversionDialog ​

File: app/components/dialogs/FileConversionDialog.vue

Modal dialog for selecting download format and resolution. Plan-gating is surfaced inline via tooltip text directing non-Pro users to upgrade.

Props ​

typescript
interface Props {
  dialog: boolean              // controls v-dialog visibility
  downloadableFormats: string[]      // list of available format strings (e.g. ['jpeg', 'png', 'webp'])
  otherResolutions: ResolutionOption[] // resolution options from asset dimensions
  file: AssetDetail            // full asset detail object
  assetConversionAllowed: boolean    // plan gate — false disables format selector
  downloading?: boolean        // shows spinner on Download button
  converting?: boolean         // shows spinner on Convert button
}

Events ​

typescript
{
  'update:dialog': [value: boolean]      // v-model passthrough for dialog open/close
  'close': []                            // dialog closed by X button
  'download-org': []                     // original file download (no conversion)
  'convert-resolution': [payload: { width: number; height: number; quality: number }]
  'convert-format': [format: string]     // convert to selected format
}

Usage Example ​

vue
<template>
  <FileConversionDialog
    v-model:dialog="showDialog"
    :downloadable-formats="['jpeg', 'png', 'webp']"
    :other-resolutions="resolutions"
    :file="assetDetail"
    :asset-conversion-allowed="plan.canConvert"
    :downloading="isDownloading"
    :converting="isConverting"
    @download-org="downloadOriginal"
    @convert-format="handleFormatConvert"
    @convert-resolution="handleResolutionConvert"
  />
</template>

<script setup lang="ts">
import type { AssetDetail, ResolutionOption } from '~/types/asset-detail'

const showDialog = ref(false)
const isDownloading = ref(false)
const isConverting = ref(false)

async function downloadOriginal() {
  isDownloading.value = true
  // trigger presigned URL download
  isDownloading.value = false
}

async function handleFormatConvert(format: string) {
  isConverting.value = true
  // call resolution.post.ts with target format
  isConverting.value = false
}

async function handleResolutionConvert(payload: { width: number; height: number; quality: number }) {
  isConverting.value = true
  // call resolution.post.ts with target dimensions
  isConverting.value = false
}
</script>

useZipDownloadApi ​

File: app/composables/api/useZipDownloadApi.ts

Posts an asset list to the external zip-worker service and returns the raw streaming Response for piping into StreamSaver.

Methods ​

typescript
{
  fetchZipStream: (payload: unknown) => Promise<Response>
  isLoading: Ref<boolean>
  error: Ref<unknown>
}

Usage Example ​

vue
<script setup lang="ts">
const { fetchZipStream } = useZipDownloadApi()
const { $streamSaver } = useNuxtApp()

async function downloadAsZip(assetIds: string[]) {
  const response = await fetchZipStream({ assetIds })
  const fileStream = $streamSaver.createWriteStream('assets.zip')
  await response.body!.pipeTo(fileStream)
}
</script>

Workflows ​

Single Asset Download ​

User clicks "Download" on AssetCard or AssetDetailPanel
  → fetch presigned URL
      GET /api/s3/get-signed-url?assetId=...&workspaceId=...
      → returns short-lived S3 presigned URL
  → trigger browser download
      GET /api/s3/download-file?url=<encoded>&name=<filename>
      → Nitro fetches source, detects MIME, sets Content-Disposition
      → streams body to browser
  → Amplitude: asset downloaded

Bulk Zip Download ​

User selects multiple assets → AssetBulkActionBar → "Download"
  → DAM store downloadMultipleFiles action
      → useZipDownloadApi.fetchZipStream({ assetIds })
          POST {ZIP_DOWNLOAD_URL}download
          → returns streaming Response (zip body)
      → $streamSaver.createWriteStream('assets.zip')
      → response.body.pipeTo(fileStream)
          [zip assembled in browser, written directly to filesystem]
  → Amplitude: assets bulk-downloaded

Format / Resolution Conversion ​

User clicks "Download" on a Pro plan workspace
  → FileConversionDialog opens
      ↓ format-only download (original resolution)
      emit 'download-org'
      → GET /api/s3/download-file?url=...&name=...
      ↓ resolution conversion
      emit 'convert-resolution' { width, height, quality }
      → POST /api/resolution { w, h, s: presignedUrl, q: quality }
          → Nitro fetches source image (30s timeout, 200MB cap)
          → readHeader() — reject if > 600MP
          → Jimp.fromBuffer() → image.resize({ w, h })
          → image.getBuffer(mime, { quality })
          → returns image buffer
      → browser triggers download of converted buffer
      ↓ format conversion
      emit 'convert-format' 'png'
      → POST /api/resolution { w, h, s: presignedUrl, q: 90, m: 'image/png' }
          → same pipeline, output mime = image/png
  → Amplitude: asset format-converted

API Integration ​

Server Routes ​

RouteMethodParamsDescription
/api/s3/download-fileGETurl, nameProxy S3 URL, set filename
/api/s3/get-signed-urlGETassetId, workspaceIdGenerate 24hr presigned URL
/api/resolutionPOSTbody { w, h, s, q, m? }Resize / re-encode image

Presigned URL Request / Response ​

json
// GET /api/s3/get-signed-url?assetId=abc123&workspaceId=7
// Authorization: Bearer eyJ...

// Response — raw URL string
"https://bucket.s3.amazonaws.com/7/digital_assets/abc123?X-Amz-Signature=..."

Resolution Conversion Request / Response ​

json
// POST /api/resolution
{
  "w": 1920,
  "h": 1080,
  "s": "https://bucket.s3.amazonaws.com/7/digital_assets/abc123?...",
  "q": 85,
  "m": "image/png"
}

// Response — raw image buffer (Content-Type: image/png)
<binary buffer>

Zip Worker Request / Response ​

json
// POST {ZIP_DOWNLOAD_URL}download
{
  "payload": {
    "assetIds": ["abc123", "def456"],
    "workspaceId": 7
  }
}

// Response — streaming zip body (Content-Type: application/zip)
<streaming body>

Component Integration ​

Download and format conversion are wired together in app/pages/[workspace_id]/dam/files/[_id]/index.vue. All download state and actions come from useAssetDetail() — there is no intermediate wrapper composable at the page level.

The download icon calls downloadAndConvertFile(), which opens FileConversionDialog when the asset is on a Pro plan and conversion is available, or triggers a direct download otherwise. FileConversionDialog emits back to the page, and the page adapts the convert-resolution payload shape before forwarding it to the composable.

vue
<!-- Download icon in title bar -->
<v-btn
  v-if="canShareDownloadAsset"
  icon
  size="large"
  variant="text"
  :disabled="downloading"
  @click="downloadAndConvertFile()"
>
  <v-progress-circular v-if="downloading" indeterminate size="14" width="2" />
  <AsyncIcon v-else name="downloadIcon" />
</v-btn>

<!-- FileConversionDialog at bottom of template -->
<FileConversionDialog
  v-if="file?.id"
  v-model:dialog="fileConversionDialog"
  :downloadable-formats="downloadableFormats"
  :other-resolutions="otherResolutions"
  :file="file"
  :asset-conversion-allowed="assetConversionAllowed"
  :downloading="downloadingWithResolution"
  :converting="converting"
  @convert-resolution="handleConvertResolution"
  @download-org="downloadFile"
  @convert-format="convertFormat"
  @close="fileConversionDialog = false"
/>
vue
<script setup lang="ts">
const {
  file,
  downloading,
  downloadingWithResolution,
  converting,
  fileConversionDialog,
  downloadableFormats,
  otherResolutions,
  assetConversionAllowed,
  canShareDownloadAsset,
  downloadAndConvertFile,
  downloadFile,
  convertFormat,
  convertResolution,
} = useAssetDetail()

// FileConversionDialog emits a single payload object;
// convertResolution takes positional args — adapt here rather than in the composable.
const handleConvertResolution = (payload: { width: number; height: number; quality: number }) => {
  convertResolution(payload.width, payload.height, payload.quality)
}
</script>