Просмотр исходного кода

Shrink imported photos to the app's upload size (#500)

* Shrink imported photos to the app's upload size

Photos brought in by the backup importers skipped the compression every
upload form runs, so they landed at several MB each. A photo is now fitted to
1600px and stored as a JPEG; PDFs and documents are kept as they came.

* Store imported files under generated names the file manager tracks

Imported files now get a UUID name like an upload's, so the nightly sweep
can prune one nothing uses and two attachments never share a file. A failed
import releases the files it wrote, and multi-page or animated images are
kept as they came instead of losing every page after the first.
Bernt Christian Egeland 3 дней назад
Родитель
Сommit
066bf4b3af

+ 141 - 0
src/__tests__/lib/backup/import-photo.test.ts

@@ -0,0 +1,141 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtemp, readFile, rm } from 'fs/promises'
+import { tmpdir } from 'os'
+import path from 'path'
+import sharp from 'sharp'
+import {
+  IMPORTED_PHOTO_SIDE,
+  isPhotoName,
+  prepareImportedFile,
+  writeImportedFile,
+} from '@/lib/backup/import-photo'
+import { isGeneratedName } from '@/lib/files/manager'
+
+// Only the manager's name rule is used here; nothing reaches the database.
+vi.mock('@/lib/files/references', () => ({
+  referencedSuffixes: async () => new Set(),
+  allReferencedSuffixes: async () => new Set(),
+  existingOrganizationIds: async () => new Set(),
+  hasAnyOrganization: async () => false,
+}))
+
+/**
+ * What an importer stores for each kind of attachment: a phone-sized photo
+ * comes down to the app's size as a JPEG, everything else is left as it came.
+ */
+
+async function photo(width: number, height: number, format: 'jpeg' | 'png' = 'jpeg') {
+  const image = sharp({
+    create: {
+      width,
+      height,
+      channels: 3,
+      background: { r: 120, g: 80, b: 40 },
+      noise: { type: 'gaussian', mean: 128, sigma: 40 },
+    },
+  })
+  return format === 'png' ? image.png().toBuffer() : image.jpeg({ quality: 100 }).toBuffer()
+}
+
+describe('prepareImportedFile', () => {
+  it('fits a large photo to the imported size and stores it as a JPEG', async () => {
+    const original = await photo(4000, 3000)
+    const out = await prepareImportedFile('job-5-20231216_131344.jpg', original)
+    expect(out.compressed).toBe(true)
+    expect(out.name).toBe('job-5-20231216_131344.jpg')
+    expect(out.type).toBe('image/jpeg')
+    expect(out.data.length).toBeLessThan(original.length)
+    const meta = await sharp(out.data).metadata()
+    expect(meta.format).toBe('jpeg')
+    expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBe(IMPORTED_PHOTO_SIDE)
+  })
+
+  it('renames a PNG photo to .jpg once re-encoded', async () => {
+    const original = await photo(3000, 2000, 'png')
+    const out = await prepareImportedFile('in-0064-Service_km_stand.PNG', original)
+    expect(out.compressed).toBe(true)
+    expect(out.name).toBe('in-0064-Service_km_stand.jpg')
+    expect(out.type).toBe('image/jpeg')
+  })
+
+  it('keeps every frame of an animated image', async () => {
+    const frames = [await photo(200, 150, 'png'), await photo(200, 150, 'png')]
+    const original = await sharp(frames, { join: { animated: true } })
+      .gif()
+      .toBuffer()
+    const out = await prepareImportedFile('walkaround.gif', original)
+    expect(out).toEqual({
+      data: original,
+      name: 'walkaround.gif',
+      type: 'image/gif',
+      compressed: false,
+    })
+  })
+
+  it('never enlarges a small photo nor makes it bigger on disk', async () => {
+    const original = await photo(200, 150)
+    const out = await prepareImportedFile('small.jpg', original)
+    expect(out.data.length).toBeLessThanOrEqual(original.length)
+    expect(out.name).toBe('small.jpg')
+    const meta = await sharp(out.data).metadata()
+    expect([meta.width, meta.height]).toEqual([200, 150])
+  })
+
+  it('leaves a PDF alone and names its type', async () => {
+    const pdf = Buffer.from('%PDF-1.4 not really')
+    const out = await prepareImportedFile('in-0062-invoice.pdf', pdf)
+    expect(out).toEqual({
+      data: pdf,
+      name: 'in-0062-invoice.pdf',
+      type: 'application/pdf',
+      compressed: false,
+    })
+  })
+
+  it('keeps a file named like a photo that sharp cannot read', async () => {
+    const junk = Buffer.from('this is not a jpeg')
+    const out = await prepareImportedFile('broken.jpg', junk)
+    expect(out.compressed).toBe(false)
+    expect(out.data).toBe(junk)
+    expect(out.type).toBe('image/jpeg')
+  })
+
+  it('knows which names are photos', () => {
+    expect(isPhotoName('a.JPG')).toBe(true)
+    expect(isPhotoName('a.heic')).toBe(true)
+    expect(isPhotoName('a.pdf')).toBe(false)
+    expect(isPhotoName('a.docx')).toBe(false)
+  })
+})
+
+describe('writeImportedFile', () => {
+  let dir: string
+  beforeEach(async () => {
+    dir = await mkdtemp(path.join(tmpdir(), 'import-photo-'))
+  })
+  afterEach(async () => {
+    await rm(dir, { recursive: true, force: true })
+  })
+
+  it('stores the file under a name the file manager sweeps', async () => {
+    const stored = await writeImportedFile(dir, 'in-0064-scan.JPG', Buffer.from('scan'))
+    expect(stored).toMatch(/\.jpg$/)
+    expect(isGeneratedName(stored)).toBe(true)
+    expect(await readFile(path.join(dir, stored), 'utf8')).toBe('scan')
+  })
+
+  it('gives two files of the same name a file each', async () => {
+    const first = await writeImportedFile(dir, 'in-0064-scan.jpg', Buffer.from('first'))
+    const second = await writeImportedFile(dir, 'in-0064-scan.jpg', Buffer.from('second'))
+    expect(first).not.toBe(second)
+    expect(await readFile(path.join(dir, first), 'utf8')).toBe('first')
+    expect(await readFile(path.join(dir, second), 'utf8')).toBe('second')
+  })
+
+  it('still gives a name the sweep knows to a file without a usable extension', async () => {
+    expect(isGeneratedName(await writeImportedFile(dir, 'notes', Buffer.from('a')))).toBe(true)
+    expect(isGeneratedName(await writeImportedFile(dir, 'a.backup2023', Buffer.from('b')))).toBe(
+      true
+    )
+  })
+})

+ 24 - 26
src/app/api/protected/backup/import-invoice-ninja/route.ts

@@ -8,7 +8,7 @@ import { isDemoMode } from '@/lib/demo'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
-import { mkdir, writeFile, rm } from 'fs/promises'
+import { mkdir, rm } from 'fs/promises'
 import path from 'path'
 import os from 'os'
 import {
@@ -20,6 +20,8 @@ import {
 } from '@/lib/backup/zip-file'
 import { resolveWithinDir } from '@/lib/safe-path'
 import { uploadsRoot } from '@/lib/upload-root'
+import { prepareImportedFile, writeImportedFile } from '@/lib/backup/import-photo'
+import { releaseFiles } from '@/lib/files/manager'
 
 // Allow up to 5 minutes for large imports
 export const maxDuration = 300
@@ -197,21 +199,6 @@ function getPaymentMethod(typeId: number): string {
   return methods[typeId] || 'other'
 }
 
-function getMimeType(filename: string): string {
-  const ext = filename.split('.').pop()?.toLowerCase()
-  const mimeMap: Record<string, string> = {
-    jpg: 'image/jpeg',
-    jpeg: 'image/jpeg',
-    png: 'image/png',
-    webp: 'image/webp',
-    pdf: 'application/pdf',
-    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-    csv: 'text/csv',
-    txt: 'text/plain',
-  }
-  return mimeMap[ext || ''] || 'application/octet-stream'
-}
-
 // ── API Route ────────────────────────────────────────────────────────────────
 
 export async function POST(request: NextRequest) {
@@ -238,6 +225,8 @@ export async function POST(request: NextRequest) {
   const timeZone = await workshopTimeZone(organizationId)
   let tmpDir: string | null = null
   let upload: SpooledUpload | null = null
+  // Every file this import writes, so a run that fails hands them back.
+  const importedFileUrls: string[] = []
 
   try {
     // The zip goes to disk as it arrives; nothing of it is held in memory
@@ -340,6 +329,7 @@ export async function POST(request: NextRequest) {
       documents: 0,
     }
 
+    // As long as the route may run: the photos are re-encoded inside.
     await db.$transaction(
       async (tx) => {
         // ── Create a single placeholder vehicle for all imported invoices ──
@@ -506,21 +496,25 @@ export async function POST(request: NextRequest) {
 
             const targetDir = path.join(uploadsBase, 'services')
             await mkdir(targetDir, { recursive: true })
+            // A photo is fitted and re-encoded on the way, so it lands at
+            // the size an upload through the app would have, and the file is
+            // stored under a generated name like an upload's, so the file
+            // manager keeps track of it.
             const safeName = doc.name.replace(/[^a-zA-Z0-9._-]/g, '_')
-            const filename = `in-${invoice.number}-${safeName}`
-            const targetPath = path.join(targetDir, filename)
-            await writeFile(targetPath, fileData)
+            const prepared = await prepareImportedFile(`in-${invoice.number}-${safeName}`, fileData)
+            const storedName = await writeImportedFile(targetDir, prepared.name, prepared.data)
 
-            const fileUrl = `/api/protected/files/${organizationId}/services/${filename}`
-            const isImage = /\.(jpg|jpeg|png|webp)$/i.test(doc.name)
+            const fileUrl = `/api/protected/files/${organizationId}/services/${storedName}`
+            importedFileUrls.push(fileUrl)
 
             await tx.serviceAttachment.create({
               data: {
-                fileName: doc.name,
+                // The user's own name, with the extension the stored bytes now have.
+                fileName: prepared.compressed ? doc.name.replace(/\.[^.]+$/, '.jpg') : doc.name,
                 fileUrl,
-                fileType: getMimeType(doc.name),
-                fileSize: fileData.length,
-                category: isImage ? 'image' : 'diagnostic',
+                fileType: prepared.type,
+                fileSize: prepared.data.length,
+                category: prepared.type.startsWith('image/') ? 'image' : 'diagnostic',
                 serviceRecordId: record.id,
               },
             })
@@ -556,7 +550,7 @@ export async function POST(request: NextRequest) {
           }
         }
       },
-      { timeout: 120000 }
+      { timeout: maxDuration * 1000 }
     )
 
     return NextResponse.json({
@@ -566,6 +560,10 @@ export async function POST(request: NextRequest) {
   } catch (error) {
     console.error('[import-invoice-ninja] Error:', error)
     console.error('[backup import]', error)
+    // The rows were rolled back, so nothing uses the files this run wrote.
+    await releaseFiles(importedFileUrls, { organizationId, reason: 'import failed' }).catch((err) =>
+      console.error('[backup import] could not release imported files:', err)
+    )
     const message = 'Import failed'
     return NextResponse.json({ error: message }, { status: 500 })
   } finally {

+ 128 - 116
src/app/api/protected/backup/import-lubelog/route.ts

@@ -8,7 +8,7 @@ import { isDemoMode } from '@/lib/demo'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
-import { mkdir, writeFile, stat, readdir, rm } from 'fs/promises'
+import { mkdir, stat, readdir, rm } from 'fs/promises'
 import { readFileSync } from 'fs'
 import path from 'path'
 import os from 'os'
@@ -16,6 +16,8 @@ import { BSON } from 'bson'
 import { extractEntry, openZip, spoolUpload, type SpooledUpload } from '@/lib/backup/zip-file'
 import { resolveWithinDir } from '@/lib/safe-path'
 import { uploadsRoot } from '@/lib/upload-root'
+import { prepareImportedFile, writeImportedFile } from '@/lib/backup/import-photo'
+import { releaseFiles } from '@/lib/files/manager'
 
 // Allow up to 5 minutes for large imports
 export const maxDuration = 300
@@ -123,21 +125,6 @@ function determineFuelType(v: LubeLogVehicle): string {
   return 'gasoline'
 }
 
-function getMimeType(filename: string): string {
-  const ext = filename.split('.').pop()?.toLowerCase()
-  const mimeMap: Record<string, string> = {
-    jpg: 'image/jpeg',
-    jpeg: 'image/jpeg',
-    png: 'image/png',
-    webp: 'image/webp',
-    pdf: 'application/pdf',
-    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-    csv: 'text/csv',
-    txt: 'text/plain',
-  }
-  return mimeMap[ext || ''] || 'application/octet-stream'
-}
-
 /**
  * Find the actual backup directory. Accepts either:
  *  - A direct path to the extracted backup folder (contains data/cartracker.db)
@@ -228,6 +215,8 @@ export async function POST(request: NextRequest) {
   const timeZone = await workshopTimeZone(organizationId)
   let tmpDir: string | null = null
   let upload: SpooledUpload | null = null
+  // Every file this import writes, so a run that fails hands them back.
+  const importedFileUrls: string[] = []
 
   try {
     // The zip comes as the raw body and goes to disk as it arrives
@@ -304,14 +293,21 @@ export async function POST(request: NextRequest) {
     const uploadsBase = path.join(uploadsRoot(), organizationId)
 
     // ── Copy a file from the backup to the uploads directory ──────────────
+    // A photo is fitted and re-encoded on the way, so it lands at the size an
+    // upload through the app would have, and every file is stored under a
+    // generated name like an upload's, so the file manager keeps track of it.
     async function copyFile(
       sourcePath: string,
       category: string,
       filename: string
-    ): Promise<{ fileUrl: string; fileSize: number } | null> {
+    ): Promise<{
+      fileUrl: string
+      fileSize: number
+      fileType: string
+      compressed: boolean
+    } | null> {
       const targetDir = path.join(uploadsBase, category)
       await mkdir(targetDir, { recursive: true })
-      const targetPath = path.join(targetDir, filename)
 
       // Normalize source paths: strip leading /
       const normalizedSource = sourcePath.replace(/^\//, '')
@@ -330,113 +326,125 @@ export async function POST(request: NextRequest) {
         return null
       }
 
-      await writeFile(targetPath, fileData)
-      const fileUrl = `/api/protected/files/${organizationId}/${category}/${filename}`
-      return { fileUrl, fileSize: fileData.length }
+      const prepared = await prepareImportedFile(filename, fileData)
+      const storedName = await writeImportedFile(targetDir, prepared.name, prepared.data)
+      const fileUrl = `/api/protected/files/${organizationId}/${category}/${storedName}`
+      importedFileUrls.push(fileUrl)
+      return {
+        fileUrl,
+        fileSize: prepared.data.length,
+        fileType: prepared.type,
+        compressed: prepared.compressed,
+      }
     }
 
     // ── Database transaction ─────────────────────────────────────────────
-    await db.$transaction(async (tx) => {
-      // Create vehicles
-      for (const v of vehicles) {
-        let imageUrl: string | null = null
-
-        if (v.ImageLocation) {
-          const ext = v.ImageLocation.split('.').pop() || 'jpg'
-          const filename = `lubelog-vehicle-${v._id}.${ext}`
-          const result = await copyFile(v.ImageLocation, 'vehicles', filename)
-          if (result) imageUrl = result.fileUrl
-        }
+    // As long as the route may run: the photos are re-encoded inside.
+    await db.$transaction(
+      async (tx) => {
+        // Create vehicles
+        for (const v of vehicles) {
+          let imageUrl: string | null = null
+
+          if (v.ImageLocation) {
+            const ext = v.ImageLocation.split('.').pop() || 'jpg'
+            const filename = `lubelog-vehicle-${v._id}.${ext}`
+            const result = await copyFile(v.ImageLocation, 'vehicles', filename)
+            if (result) imageUrl = result.fileUrl
+          }
 
-        const created = await tx.vehicle.create({
-          data: {
-            make: v.Make,
-            model: v.Model,
-            year: v.Year,
-            licensePlate: v.LicensePlate || null,
-            fuelType: determineFuelType(v),
-            mileage: vehicleMileage.get(v._id) || 0,
-            purchasePrice: parseDecimal(v.PurchasePrice) || null,
-            imageUrl,
-            userId,
-            organizationId,
-          },
-        })
-
-        vehicleIdMap.set(v._id, created.id)
-      }
+          const created = await tx.vehicle.create({
+            data: {
+              make: v.Make,
+              model: v.Model,
+              year: v.Year,
+              licensePlate: v.LicensePlate || null,
+              fuelType: determineFuelType(v),
+              mileage: vehicleMileage.get(v._id) || 0,
+              purchasePrice: parseDecimal(v.PurchasePrice) || null,
+              imageUrl,
+              userId,
+              organizationId,
+            },
+          })
+
+          vehicleIdMap.set(v._id, created.id)
+        }
 
-      // Create notes (grouped by vehicle)
-      for (const n of notes) {
-        const vehicleId = vehicleIdMap.get(n.VehicleId)
-        if (!vehicleId) continue
-
-        await tx.note.create({
-          data: {
-            title: n.Description,
-            content: n.NoteText,
-            isPinned: n.Pinned || false,
-            vehicleId,
-          },
-        })
-      }
+        // Create notes (grouped by vehicle)
+        for (const n of notes) {
+          const vehicleId = vehicleIdMap.get(n.VehicleId)
+          if (!vehicleId) continue
+
+          await tx.note.create({
+            data: {
+              title: n.Description,
+              content: n.NoteText,
+              isPinned: n.Pinned || false,
+              vehicleId,
+            },
+          })
+        }
 
-      // Create service records
-      for (const sr of serviceRecords) {
-        const vehicleId = vehicleIdMap.get(sr.VehicleId)
-        if (!vehicleId) continue
-
-        const invoiceNumber = `${invoicePrefix}${nextInvoiceNum}`
-        nextInvoiceNum++
-
-        const cost = parseDecimal(sr.Cost)
-        const serviceDate = toSafeWorkshopDate(sr.Date, timeZone) ?? new Date()
-
-        const created = await tx.serviceRecord.create({
-          data: {
-            organizationId,
-            title: sr.Description,
-            description: sr.Notes || null,
-            type: 'repair',
-            status: 'completed',
-            cost,
-            mileage: sr.Mileage || null,
-            serviceDate,
-            // Reports and the calendar find a job by when it started; a job
-            // without a start is in none of them.
-            startDateTime: serviceDate,
-            invoiceNumber,
-            subtotal: cost,
-            totalAmount: cost,
-            vehicleId,
-          },
-        })
-
-        // Copy attached files
-        if (sr.Files && sr.Files.length > 0) {
-          for (const file of sr.Files) {
-            if (!file.Location) continue
-
-            const safeName = file.Name.replace(/[^a-zA-Z0-9._-]/g, '_')
-            const filename = `lubelog-${sr._id}-${safeName}`
-
-            const result = await copyFile(file.Location, 'services', filename)
-            if (result) {
-              await tx.serviceAttachment.create({
-                data: {
-                  fileName: file.Name,
-                  fileUrl: result.fileUrl,
-                  fileType: getMimeType(file.Name),
-                  fileSize: result.fileSize,
-                  category: file.Name.match(/\.(jpg|jpeg|png|webp)$/i) ? 'image' : 'diagnostic',
-                  serviceRecordId: created.id,
-                },
-              })
+        // Create service records
+        for (const sr of serviceRecords) {
+          const vehicleId = vehicleIdMap.get(sr.VehicleId)
+          if (!vehicleId) continue
+
+          const invoiceNumber = `${invoicePrefix}${nextInvoiceNum}`
+          nextInvoiceNum++
+
+          const cost = parseDecimal(sr.Cost)
+          const serviceDate = toSafeWorkshopDate(sr.Date, timeZone) ?? new Date()
+
+          const created = await tx.serviceRecord.create({
+            data: {
+              organizationId,
+              title: sr.Description,
+              description: sr.Notes || null,
+              type: 'repair',
+              status: 'completed',
+              cost,
+              mileage: sr.Mileage || null,
+              serviceDate,
+              // Reports and the calendar find a job by when it started; a job
+              // without a start is in none of them.
+              startDateTime: serviceDate,
+              invoiceNumber,
+              subtotal: cost,
+              totalAmount: cost,
+              vehicleId,
+            },
+          })
+
+          // Copy attached files
+          if (sr.Files && sr.Files.length > 0) {
+            for (const file of sr.Files) {
+              if (!file.Location) continue
+
+              const safeName = file.Name.replace(/[^a-zA-Z0-9._-]/g, '_')
+              const filename = `lubelog-${sr._id}-${safeName}`
+
+              const result = await copyFile(file.Location, 'services', filename)
+              if (result) {
+                await tx.serviceAttachment.create({
+                  data: {
+                    // The user's own name, with the extension the stored bytes now have.
+                    fileName: result.compressed ? file.Name.replace(/\.[^.]+$/, '.jpg') : file.Name,
+                    fileUrl: result.fileUrl,
+                    fileType: result.fileType,
+                    fileSize: result.fileSize,
+                    category: result.fileType.startsWith('image/') ? 'image' : 'diagnostic',
+                    serviceRecordId: created.id,
+                  },
+                })
+              }
             }
           }
         }
-      }
-    })
+      },
+      { timeout: maxDuration * 1000 }
+    )
 
     return NextResponse.json({
       success: true,
@@ -449,6 +457,10 @@ export async function POST(request: NextRequest) {
   } catch (error) {
     console.error('[import-lubelog] Error:', error)
     console.error('[backup import]', error)
+    // The rows were rolled back, so nothing uses the files this run wrote.
+    await releaseFiles(importedFileUrls, { organizationId, reason: 'import failed' }).catch((err) =>
+      console.error('[backup import] could not release imported files:', err)
+    )
     const message = 'Import failed'
     return NextResponse.json({ error: message }, { status: 500 })
   } finally {

+ 96 - 0
src/lib/backup/import-photo.ts

@@ -0,0 +1,96 @@
+import 'server-only'
+
+import { randomUUID } from 'crypto'
+import { writeFile } from 'fs/promises'
+import path from 'path'
+import sharp from 'sharp'
+import { compressPhoto } from '@/lib/image-upload.server'
+
+/**
+ * A file an importer brings over from another system, made the
+ * size the app's own upload paths would have made it.
+ *
+ * Other systems hand their attachments over as the phone took them, several
+ * MB a photo, and the browser-side compression every upload form runs never
+ * sees them. A workshop's first platform restore point was mostly those
+ * files. So a photo is fitted to IMPORTED_PHOTO_SIDE and saved as a JPEG,
+ * under a name that says so; anything else, a PDF, a document, an image
+ * with several pages or frames, or an image sharp cannot read, is stored as
+ * it came.
+ */
+
+/** Longest side of an imported photo; a little above the forms' 1200 so a document photo stays legible. */
+export const IMPORTED_PHOTO_SIDE = 1600
+
+const PHOTO_NAME = /\.(jpe?g|png|webp|gif|heic|heif|avif|tiff?)$/i
+
+export interface ImportedFile {
+  data: Buffer
+  /** The name to store and show, with the extension the bytes now have. */
+  name: string
+  /** The MIME type of `data`. */
+  type: string
+  /** Whether the bytes were re-encoded. */
+  compressed: boolean
+}
+
+function mimeFor(name: string): string {
+  const ext = name.split('.').pop()?.toLowerCase()
+  const types: Record<string, string> = {
+    jpg: 'image/jpeg',
+    jpeg: 'image/jpeg',
+    png: 'image/png',
+    webp: 'image/webp',
+    gif: 'image/gif',
+    pdf: 'application/pdf',
+    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    csv: 'text/csv',
+    txt: 'text/plain',
+  }
+  return types[ext ?? ''] ?? 'application/octet-stream'
+}
+
+/** Whether the name is one an importer treats as a photo. */
+export function isPhotoName(name: string): boolean {
+  return PHOTO_NAME.test(name)
+}
+
+export async function prepareImportedFile(name: string, data: Buffer): Promise<ImportedFile> {
+  if (!isPhotoName(name)) return { data, name, type: mimeFor(name), compressed: false }
+  try {
+    // A JPEG holds one page, so a scanned multi-page TIFF or an animated
+    // GIF or WebP would lose everything after the first.
+    const { pages } = await sharp(data).metadata()
+    if ((pages ?? 1) > 1) return { data, name, type: mimeFor(name), compressed: false }
+
+    const photo = await compressPhoto(data, IMPORTED_PHOTO_SIDE)
+    // Only a smaller file is worth the re-encode; a small, already-fitted
+    // photo keeps its bytes and its format.
+    if (photo.data.length >= data.length) {
+      return { data, name, type: mimeFor(name), compressed: false }
+    }
+    return {
+      data: photo.data,
+      name: name.replace(PHOTO_NAME, '.jpg'),
+      type: 'image/jpeg',
+      compressed: true,
+    }
+  } catch {
+    // Not an image sharp can read: kept as it came, like any other document.
+    return { data, name, type: mimeFor(name), compressed: false }
+  }
+}
+
+/**
+ * Writes an imported file into `dir` under a generated name, a random UUID
+ * with `name`'s extension, and returns that name. It is the shape the upload
+ * routes give their files, so the file manager's sweep finds an imported file
+ * nothing uses any more, and two attachments never share one file. The name
+ * the user knows the file by is kept on its row.
+ */
+export async function writeImportedFile(dir: string, name: string, data: Buffer): Promise<string> {
+  const ext = path.extname(name).slice(1).toLowerCase()
+  const stored = `${randomUUID()}.${/^[a-z0-9]{1,8}$/.test(ext) ? ext : 'bin'}`
+  await writeFile(path.join(dir, stored), data, { flag: 'wx' })
+  return stored
+}

+ 10 - 4
src/lib/files/manager.ts

@@ -61,14 +61,20 @@ const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/
 const STORED_URL = /^\/api\/(?:protected\/)?files\/([^/]+)\/([^/]+)\/([^/]+)$/
 
 /**
- * Names the upload routes generate: a random UUID and an extension, or a
- * video's `_orig` copy that is only there while it is compressed. The sweep
- * only ever considers these; a file named any other way (an import's, the
- * demo seed's) was put there on purpose by something else.
+ * Names the upload routes and the backup importers generate: a random UUID
+ * and an extension, or a video's `_orig` copy that is only there while it is
+ * compressed. The sweep only ever considers these; a file named any other way
+ * (an older import's, the demo seed's) was put there on purpose by something
+ * else.
  */
 const GENERATED_NAME =
   /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:_orig)?\.[A-Za-z0-9]{1,8}$/
 
+/** Whether the sweep would consider a file of this name. */
+export function isGeneratedName(name: string): boolean {
+  return GENERATED_NAME.test(name)
+}
+
 export interface StoredFile {
   organizationId: string
   folder: string