• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / storage / 17737306154

15 Sep 2025 02:54PM UTC coverage: 76.473% (+0.5%) from 75.974%
17737306154

push

github

web-flow
feat: add support for sorting in list v2 endpoint (#749)

* feat: add support for sorting in list v2 endpoint

* add test cases, fix time sorting in flat file listings

* move order into sortby in database adapter

* remove unused variable in query

1714 of 2477 branches covered (69.2%)

Branch coverage included in aggregate %.

137 of 139 new or added lines in 5 files covered. (98.56%)

13 existing lines in 2 files now uncovered.

21136 of 27403 relevant lines covered (77.13%)

100.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.05
/src/storage/object.ts
1
import { randomUUID } from 'node:crypto'
1✔
2
import { SignedUploadToken, signJWT, verifyJWT } from '@internal/auth'
1✔
3
import { ERRORS } from '@internal/errors'
1✔
4
import { getJwtSecret } from '@internal/database'
1✔
5

1✔
6
import { ObjectMetadata, StorageBackendAdapter } from './backend'
1✔
7
import { Database, FindObjectFilters, SearchObjectOption } from './database'
1✔
8
import { mustBeValidKey } from './limits'
1✔
9
import { fileUploadFromRequest, Uploader, UploadRequest } from './uploader'
1✔
10
import { getConfig } from '../config'
1✔
11
import {
1✔
12
  ObjectAdminDelete,
1✔
13
  ObjectCreatedCopyEvent,
1✔
14
  ObjectCreatedMove,
1✔
15
  ObjectRemoved,
1✔
16
  ObjectRemovedMove,
1✔
17
  ObjectUpdatedMetadata,
1✔
18
} from './events'
1✔
19
import { FastifyRequest } from 'fastify/types/request'
1✔
20
import { Obj } from '@storage/schemas'
1✔
21
import { StorageObjectLocator } from '@storage/locator'
1✔
22

1✔
23
const { requestUrlLengthLimit } = getConfig()
1✔
24

1✔
25
interface CopyObjectParams {
1✔
26
  sourceKey: string
1✔
27
  destinationBucket: string
1✔
28
  destinationKey: string
1✔
29
  owner?: string
1✔
30
  copyMetadata?: boolean
1✔
31
  upsert?: boolean
1✔
32
  metadata?: {
1✔
33
    cacheControl?: string
1✔
34
    mimetype?: string
1✔
35
  }
1✔
36
  userMetadata?: Record<string, unknown>
1✔
37
  conditions?: {
1✔
38
    ifMatch?: string
1✔
39
    ifNoneMatch?: string
1✔
40
    ifModifiedSince?: Date
1✔
41
    ifUnmodifiedSince?: Date
1✔
42
  }
1✔
43
}
1✔
44
export interface ListObjectsV2Result {
1✔
45
  folders: Obj[]
1✔
46
  objects: Obj[]
1✔
47
  hasNext: boolean
1✔
48
  nextCursor?: string
1✔
49
}
1✔
50

1✔
51
/**
1✔
52
 * ObjectStorage
1✔
53
 * interact with remote objects and database state
1✔
54
 */
1✔
55
export class ObjectStorage {
1✔
56
  protected readonly uploader: Uploader
1✔
57

1✔
58
  constructor(
1✔
59
    private readonly backend: StorageBackendAdapter,
108✔
60
    private readonly db: Database,
108✔
61
    private readonly location: StorageObjectLocator,
108✔
62
    private readonly bucketId: string
108✔
63
  ) {
108✔
64
    this.uploader = new Uploader(backend, db, location)
108✔
65
  }
108✔
66

1✔
67
  /**
1✔
68
   * Impersonate any subsequent chained operations
1✔
69
   * as superUser bypassing RLS rules
1✔
70
   */
1✔
71
  asSuperUser() {
1✔
72
    return new ObjectStorage(this.backend, this.db.asSuperUser(), this.location, this.bucketId)
1✔
73
  }
1✔
74

1✔
75
  async uploadFromRequest(
1✔
76
    request: FastifyRequest,
39✔
77
    file: {
39✔
78
      objectName: string
39✔
79
      owner?: string
39✔
80
      isUpsert: boolean
39✔
81
      signal?: AbortSignal
39✔
82
    }
39✔
83
  ) {
39✔
84
    const bucket = await this.db
39✔
85
      .asSuperUser()
39✔
86
      .findBucketById(this.bucketId, 'id, file_size_limit, allowed_mime_types')
39✔
87

33✔
88
    const uploadRequest = await fileUploadFromRequest(request, {
33✔
89
      objectName: file.objectName,
33✔
90
      fileSizeLimit: bucket.file_size_limit,
33✔
91
      allowedMimeTypes: bucket.allowed_mime_types || [],
39✔
92
    })
39✔
93

31✔
94
    return this.uploadNewObject({
31✔
95
      file: uploadRequest,
31✔
96
      objectName: file.objectName,
31✔
97
      owner: file.owner,
31✔
98
      isUpsert: Boolean(file.isUpsert),
31✔
99
      signal: file.signal,
31✔
100
    })
31✔
101
  }
31✔
102

1✔
103
  /**
1✔
104
   * Upload a new object to a storage
1✔
105
   * @param request
1✔
106
   */
1✔
107
  async uploadNewObject(request: Omit<UploadRequest, 'bucketId' | 'uploadType'>) {
1✔
108
    mustBeValidKey(request.objectName)
31✔
109

31✔
110
    const path = `${this.bucketId}/${request.objectName}`
31✔
111

31✔
112
    const { metadata, obj } = await this.uploader.upload({
31✔
113
      ...request,
31✔
114
      bucketId: this.bucketId,
31✔
115
      uploadType: 'standard',
31✔
116
    })
31✔
117

16✔
118
    return { objectMetadata: metadata, path, id: obj.id }
16✔
119
  }
16✔
120

1✔
121
  /**
1✔
122
   * Deletes an object from the remote storage
1✔
123
   * and the database
1✔
124
   * @param objectName
1✔
125
   */
1✔
126
  async deleteObject(objectName: string) {
1✔
127
    const obj = await this.db.withTransaction(async (db) => {
4✔
128
      const obj = await db.asSuperUser().findObject(this.bucketId, objectName, 'id,version', {
4✔
129
        forUpdate: true,
4✔
130
      })
4✔
131

2✔
132
      const deleted = await db.deleteObject(this.bucketId, objectName)
2✔
133

2✔
134
      if (!deleted) {
4✔
135
        throw ERRORS.NoSuchKey(objectName)
1✔
136
      }
1✔
137

1✔
138
      await this.backend.deleteObject(
1✔
139
        this.location.getRootLocation(),
1✔
140
        this.location.getKeyLocation({
1✔
141
          tenantId: this.db.tenantId,
1✔
142
          bucketId: this.bucketId,
1✔
143
          objectName,
1✔
144
        }),
1✔
145
        obj.version
1✔
146
      )
1✔
147

1✔
148
      return obj
1✔
149
    })
4✔
150

1✔
151
    await ObjectRemoved.sendWebhook({
1✔
152
      tenant: this.db.tenant(),
1✔
153
      name: objectName,
1✔
154
      version: obj.version,
1✔
155
      bucketId: this.bucketId,
1✔
156
      reqId: this.db.reqId,
1✔
157
      metadata: obj.metadata,
1✔
158
    })
1✔
159
  }
1✔
160

1✔
161
  /**
1✔
162
   * Deletes multiple objects from the remote storage
1✔
163
   * and the database
1✔
164
   * @param prefixes
1✔
165
   */
1✔
166
  async deleteObjects(prefixes: string[]) {
1✔
167
    let results: { name: string }[] = []
5✔
168

5✔
169
    for (let i = 0; i < prefixes.length; ) {
5✔
170
      const prefixesSubset: string[] = []
43✔
171
      let urlParamLength = 0
43✔
172

43✔
173
      for (; i < prefixes.length && urlParamLength < requestUrlLengthLimit; i++) {
43✔
174
        const prefix = prefixes[i]
10,009✔
175
        prefixesSubset.push(prefix)
10,009✔
176
        urlParamLength += encodeURIComponent(prefix).length + 9 // length of '%22%2C%22'
10,009✔
177
      }
10,009✔
178

43✔
179
      await this.db.withTransaction(async (db) => {
43✔
180
        const data = await db.deleteObjects(this.bucketId, prefixesSubset, 'name')
43✔
181

43✔
182
        if (data.length > 0) {
43✔
183
          results = results.concat(data)
40✔
184

40✔
185
          // if successfully deleted, delete from s3 too
40✔
186
          // todo: consider moving this to a queue
40✔
187
          const prefixesToDelete = data.reduce((all, { name, version }) => {
40✔
188
            all.push(
10,002✔
189
              this.location.getKeyLocation({
10,002✔
190
                tenantId: db.tenantId,
10,002✔
191
                bucketId: this.bucketId,
10,002✔
192
                objectName: name,
10,002✔
193
                version,
10,002✔
194
              })
10,002✔
195
            )
10,002✔
196

10,002✔
197
            if (version) {
10,002!
198
              all.push(
×
199
                this.location.getKeyLocation({
×
200
                  tenantId: db.tenantId,
×
201
                  bucketId: this.bucketId,
×
202
                  objectName: name,
×
203
                  version,
×
204
                }) + '.info'
×
205
              )
×
206
            }
×
207
            return all
10,002✔
208
          }, [] as string[])
40✔
209

40✔
210
          await this.backend.deleteObjects(this.location.getRootLocation(), prefixesToDelete)
40✔
211

40✔
212
          await Promise.allSettled(
40✔
213
            data.map((object) =>
40✔
214
              ObjectRemoved.sendWebhook({
10,002✔
215
                tenant: db.tenant(),
10,002✔
216
                name: object.name,
10,002✔
217
                bucketId: this.bucketId,
10,002✔
218
                reqId: this.db.reqId,
10,002✔
219
                version: object.version,
10,002✔
220
                metadata: object.metadata,
10,002✔
221
              })
10,002✔
222
            )
40✔
223
          )
40✔
224
        }
40✔
225
      })
43✔
226
    }
43✔
227

43✔
228
    return results
43✔
229
  }
43✔
230

1✔
231
  /**
1✔
232
   * Updates object metadata in the database
1✔
233
   * @param objectName
1✔
234
   * @param metadata
1✔
235
   */
1✔
236
  async updateObjectMetadata(objectName: string, metadata: ObjectMetadata) {
1✔
237
    mustBeValidKey(objectName)
×
238

×
239
    const result = await this.db.updateObjectMetadata(this.bucketId, objectName, metadata)
×
240

×
241
    await ObjectUpdatedMetadata.sendWebhook({
×
242
      tenant: this.db.tenant(),
×
243
      name: objectName,
×
244
      version: result.version,
×
245
      bucketId: this.bucketId,
×
246
      metadata,
×
247
      reqId: this.db.reqId,
×
248
    })
×
249

×
250
    return result
×
251
  }
×
252

1✔
253
  /**
1✔
254
   * Updates the owner of an object in the database
1✔
255
   * @param objectName
1✔
256
   * @param owner
1✔
257
   */
1✔
258
  updateObjectOwner(objectName: string, owner?: string) {
1✔
259
    return this.db.updateObjectOwner(this.bucketId, objectName, owner)
×
260
  }
×
261

1✔
262
  /**
1✔
263
   * Finds an object by name
1✔
264
   * @param objectName
1✔
265
   * @param columns
1✔
266
   * @param filters
1✔
267
   */
1✔
268
  async findObject(objectName: string, columns = 'id', filters?: FindObjectFilters) {
1✔
269
    mustBeValidKey(objectName)
21✔
270

21✔
271
    return this.db.findObject(this.bucketId, objectName, columns, filters)
21✔
272
  }
21✔
273

1✔
274
  /**
1✔
275
   * Find multiple objects by name
1✔
276
   * @param objectNames
1✔
277
   * @param columns
1✔
278
   */
1✔
279
  async findObjects(objectNames: string[], columns = 'id') {
1✔
280
    return this.db.findObjects(this.bucketId, objectNames, columns)
118✔
281
  }
118✔
282

1✔
283
  /**
1✔
284
   * Copies an existing remote object to a given location
1✔
285
   * @param sourceKey
1✔
286
   * @param destinationBucket
1✔
287
   * @param destinationKey
1✔
288
   * @param owner
1✔
289
   * @param conditions
1✔
290
   * @param copyMetadata
1✔
291
   * @param upsert
1✔
292
   * @param fileMetadata
1✔
293
   * @param userMetadata
1✔
294
   */
1✔
295
  async copyObject({
1✔
296
    sourceKey,
9✔
297
    destinationBucket,
9✔
298
    destinationKey,
9✔
299
    owner,
9✔
300
    conditions,
9✔
301
    copyMetadata,
9✔
302
    upsert,
9✔
303
    metadata: fileMetadata,
9✔
304
    userMetadata,
9✔
305
  }: CopyObjectParams) {
9✔
306
    mustBeValidKey(destinationKey)
9✔
307

9✔
308
    const newVersion = randomUUID()
9✔
309
    const s3SourceKey = this.location.getKeyLocation({
9✔
310
      tenantId: this.db.tenantId,
9✔
311
      bucketId: this.bucketId,
9✔
312
      objectName: sourceKey,
9✔
313
    })
9✔
314
    const s3DestinationKey = this.location.getKeyLocation({
9✔
315
      tenantId: this.db.tenantId,
9✔
316
      bucketId: destinationBucket,
9✔
317
      objectName: destinationKey,
9✔
318
    })
9✔
319

9✔
320
    // We check if the user has permission to copy the object to the destination key
9✔
321
    const originObject = await this.db.findObject(
9✔
322
      this.bucketId,
9✔
323
      sourceKey,
9✔
324
      'bucket_id,metadata,user_metadata,version'
9✔
325
    )
9✔
326

6✔
327
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
6✔
328
    const baseMetadata = originObject.metadata || {}
9!
329
    const destinationMetadata = copyMetadata
9✔
330
      ? baseMetadata
9✔
331
      : {
9✔
332
          ...baseMetadata,
2✔
333
          ...(fileMetadata || {}),
2✔
334
        }
2✔
335

9✔
336
    await this.uploader.canUpload({
9✔
337
      bucketId: destinationBucket,
9✔
338
      objectName: destinationKey,
9✔
339
      owner,
9✔
340
      isUpsert: upsert,
9✔
341
    })
9✔
342

5✔
343
    try {
5✔
344
      const copyResult = await this.backend.copyObject(
5✔
345
        this.location.getRootLocation(),
5✔
346
        s3SourceKey,
5✔
347
        originObject.version,
5✔
348
        s3DestinationKey,
5✔
349
        newVersion,
5✔
350
        destinationMetadata,
5✔
351
        conditions
5✔
352
      )
5✔
353

5✔
354
      const metadata = await this.backend.headObject(
5✔
355
        this.location.getRootLocation(),
5✔
356
        s3DestinationKey,
5✔
357
        newVersion
5✔
358
      )
5✔
359

5✔
360
      const destinationObject = await this.db.asSuperUser().withTransaction(async (db) => {
5✔
361
        await db.waitObjectLock(destinationBucket, destinationKey, undefined, {
5✔
362
          timeout: 3000,
5✔
363
        })
5✔
364

5✔
365
        const existingDestObject = await db.findObject(
5✔
366
          destinationBucket,
5✔
367
          destinationKey,
5✔
368
          'id,name,metadata,version,bucket_id',
5✔
369
          {
5✔
370
            dontErrorOnEmpty: true,
5✔
371
            forUpdate: true,
5✔
372
          }
5✔
373
        )
5✔
374

5✔
375
        const destinationObject = await db.upsertObject({
5✔
376
          ...originObject,
5✔
377
          bucket_id: destinationBucket,
5✔
378
          name: destinationKey,
5✔
379
          owner,
5✔
380
          metadata: {
5✔
381
            ...destinationMetadata,
5✔
382
            lastModified: copyResult.lastModified,
5✔
383
            eTag: copyResult.eTag,
5✔
384
          },
5✔
385
          user_metadata: copyMetadata ? originObject.user_metadata : userMetadata,
5✔
386
          version: newVersion,
5✔
387
        })
5✔
388

5✔
389
        if (existingDestObject) {
5✔
390
          await ObjectAdminDelete.send({
1✔
391
            name: existingDestObject.name,
1✔
392
            bucketId: existingDestObject.bucket_id,
1✔
393
            tenant: this.db.tenant(),
1✔
394
            version: existingDestObject.version,
1✔
395
            reqId: this.db.reqId,
1✔
396
          })
1✔
397
        }
1✔
398

5✔
399
        return destinationObject
5✔
400
      })
5✔
401

5✔
402
      await ObjectCreatedCopyEvent.sendWebhook({
5✔
403
        tenant: this.db.tenant(),
5✔
404
        name: destinationKey,
5✔
405
        version: newVersion,
5✔
406
        bucketId: this.bucketId,
5✔
407
        metadata,
5✔
408
        reqId: this.db.reqId,
5✔
409
      })
5✔
410

5✔
411
      return {
5✔
412
        destObject: destinationObject,
5✔
413
        httpStatusCode: copyResult.httpStatusCode,
5✔
414
        eTag: copyResult.eTag,
5✔
415
        lastModified: copyResult.lastModified,
5✔
416
      }
5✔
417
    } catch (e) {
9!
418
      await ObjectAdminDelete.send({
×
419
        name: destinationKey,
×
420
        bucketId: destinationBucket,
×
421
        tenant: this.db.tenant(),
×
422
        version: newVersion,
×
423
        reqId: this.db.reqId,
×
424
      })
×
425
      throw e
×
426
    }
×
427
  }
9✔
428

1✔
429
  /**
1✔
430
   * Moves an existing remote object to a given location
1✔
431
   * @param sourceObjectName
1✔
432
   * @param destinationBucket
1✔
433
   * @param destinationObjectName
1✔
434
   * @param owner
1✔
435
   */
1✔
436
  async moveObject(
1✔
437
    sourceObjectName: string,
7✔
438
    destinationBucket: string,
7✔
439
    destinationObjectName: string,
7✔
440
    owner?: string
7✔
441
  ) {
7✔
442
    mustBeValidKey(destinationObjectName)
7✔
443

7✔
444
    const newVersion = randomUUID()
7✔
445
    const s3SourceKey = this.location.getKeyLocation({
7✔
446
      tenantId: this.db.tenantId,
7✔
447
      bucketId: this.bucketId,
7✔
448
      objectName: sourceObjectName,
7✔
449
    })
7✔
450

7✔
451
    const s3DestinationKey = this.location.getKeyLocation({
7✔
452
      tenantId: this.db.tenantId,
7✔
453
      bucketId: destinationBucket,
7✔
454
      objectName: destinationObjectName,
7✔
455
    })
7✔
456

7✔
457
    await this.db.testPermission((db) => {
7✔
458
      return Promise.all([
7✔
459
        db.findObject(this.bucketId, sourceObjectName, 'id'),
7✔
460
        db.updateObject(this.bucketId, sourceObjectName, {
7✔
461
          name: destinationObjectName,
7✔
462
          version: newVersion,
7✔
463
          bucket_id: destinationBucket,
7✔
464
          owner,
7✔
465
        }),
7✔
466
      ])
7✔
467
    })
7✔
468

2✔
469
    const sourceObj = await this.db
2✔
470
      .asSuperUser()
2✔
471
      .findObject(this.bucketId, sourceObjectName, 'id, version,user_metadata')
2✔
472

2✔
473
    if (s3SourceKey === s3DestinationKey) {
7!
474
      return {
×
475
        destObject: sourceObj,
×
476
      }
×
477
    }
×
478

2✔
479
    try {
2✔
480
      await this.backend.copyObject(
2✔
481
        this.location.getRootLocation(),
2✔
482
        s3SourceKey,
2✔
483
        sourceObj.version,
2✔
484
        s3DestinationKey,
2✔
485
        newVersion
2✔
486
      )
2✔
487

2✔
488
      const metadata = await this.backend.headObject(
2✔
489
        this.location.getRootLocation(),
2✔
490
        s3DestinationKey,
2✔
491
        newVersion
2✔
492
      )
2✔
493

2✔
494
      return this.db.asSuperUser().withTransaction(async (db) => {
2✔
495
        await db.waitObjectLock(this.bucketId, destinationObjectName, undefined, {
2✔
496
          timeout: 5000,
2✔
497
        })
2✔
498

2✔
499
        const sourceObject = await db.findObject(
2✔
500
          this.bucketId,
2✔
501
          sourceObjectName,
2✔
502
          'id,version,metadata,user_metadata',
2✔
503
          {
2✔
504
            forUpdate: true,
2✔
505
            dontErrorOnEmpty: false,
2✔
506
          }
2✔
507
        )
2✔
508

2✔
509
        await db.updateObject(this.bucketId, sourceObjectName, {
2✔
510
          name: destinationObjectName,
2✔
511
          bucket_id: destinationBucket,
2✔
512
          version: newVersion,
2✔
513
          owner: owner,
2✔
514
          metadata,
2✔
515
          user_metadata: sourceObj.user_metadata,
2✔
516
        })
2✔
517

2✔
518
        await ObjectAdminDelete.send({
2✔
519
          name: sourceObjectName,
2✔
520
          bucketId: this.bucketId,
2✔
521
          tenant: this.db.tenant(),
2✔
522
          version: sourceObj.version,
2✔
523
          reqId: this.db.reqId,
2✔
524
        })
2✔
525

2✔
526
        await Promise.allSettled([
2✔
527
          ObjectRemovedMove.sendWebhook({
2✔
528
            tenant: this.db.tenant(),
2✔
529
            name: sourceObjectName,
2✔
530
            bucketId: this.bucketId,
2✔
531
            reqId: this.db.reqId,
2✔
532
            version: sourceObject.version,
2✔
533
            metadata: sourceObject.metadata,
2✔
534
          }),
2✔
535
          ObjectCreatedMove.sendWebhook({
2✔
536
            tenant: this.db.tenant(),
2✔
537
            name: destinationObjectName,
2✔
538
            version: newVersion,
2✔
539
            bucketId: this.bucketId,
2✔
540
            metadata: metadata,
2✔
541
            oldObject: {
2✔
542
              name: sourceObjectName,
2✔
543
              bucketId: this.bucketId,
2✔
544
              reqId: this.db.reqId,
2✔
545
              version: sourceObject.version,
2✔
546
            },
2✔
547
            reqId: this.db.reqId,
2✔
548
          }),
2✔
549
        ])
2✔
550

2✔
551
        return {
2✔
552
          destObject: {
2✔
553
            id: sourceObject.id,
2✔
554
            name: destinationObjectName,
2✔
555
            bucket_id: destinationBucket,
2✔
556
            version: newVersion,
2✔
557
            owner: owner,
2✔
558
            metadata,
2✔
559
          },
2✔
560
        }
2✔
561
      })
2✔
562
    } catch (e) {
7!
563
      await ObjectAdminDelete.send({
×
564
        name: destinationObjectName,
×
565
        bucketId: this.bucketId,
×
566
        tenant: this.db.tenant(),
×
567
        version: newVersion,
×
568
        reqId: this.db.reqId,
×
569
      })
×
570
      throw e
×
571
    }
×
572
  }
7✔
573

1✔
574
  /**
1✔
575
   * Search objects by prefix
1✔
576
   * @param prefix
1✔
577
   * @param options
1✔
578
   */
1✔
579
  async searchObjects(prefix: string, options: SearchObjectOption) {
1✔
580
    if (prefix.length > 0 && !prefix.endsWith('/')) {
8✔
581
      // assuming prefix is always a folder
2✔
582
      prefix = `${prefix}/`
2✔
583
    }
2✔
584

8✔
585
    return this.db.searchObjects(this.bucketId, prefix, options)
8✔
586
  }
8✔
587

1✔
588
  async listObjectsV2(options?: {
1✔
589
    prefix?: string
173✔
590
    delimiter?: string
173✔
591
    cursor?: string
173✔
592
    startAfter?: string
173✔
593
    maxKeys?: number
173✔
594
    encodingType?: 'url'
173✔
595
    sortBy?: {
173✔
596
      column: 'name' | 'created_at' | 'updated_at'
173✔
597
      order?: string
173✔
598
    }
173✔
599
  }): Promise<ListObjectsV2Result> {
173✔
600
    const limit = Math.min(options?.maxKeys || 1000, 1000)
173!
601
    const prefix = options?.prefix || ''
173✔
602
    const delimiter = options?.delimiter
173✔
603

173✔
604
    const cursor = options?.cursor ? decodeContinuationToken(options.cursor) : undefined
173✔
605
    let searchResult = await this.db.listObjectsV2(this.bucketId, {
173✔
606
      prefix: options?.prefix,
173✔
607
      delimiter: options?.delimiter,
173✔
608
      maxKeys: limit + 1,
173✔
609
      nextToken: cursor?.startAfter,
173✔
610
      startAfter: cursor?.startAfter || options?.startAfter,
173✔
611
      sortBy: {
173✔
612
        order: cursor?.sortOrder || options?.sortBy?.order,
173✔
613
        column: cursor?.sortColumn || options?.sortBy?.column,
173✔
614
        after: cursor?.sortColumnAfter,
173✔
615
      },
173✔
616
    })
173✔
617

173✔
618
    let prevPrefix = ''
173✔
619

173✔
620
    if (delimiter) {
173✔
621
      const delimitedResults: Obj[] = []
67✔
622
      for (const object of searchResult) {
67✔
623
        let idx = object.name.replace(prefix, '').indexOf(delimiter)
362✔
624

362✔
625
        if (idx >= 0) {
362!
UNCOV
626
          idx = prefix.length + idx + delimiter.length
×
UNCOV
627
          const currPrefix = object.name.substring(0, idx)
×
UNCOV
628
          if (currPrefix === prevPrefix) {
×
629
            continue
×
630
          }
×
UNCOV
631
          prevPrefix = currPrefix
×
UNCOV
632
          delimitedResults.push({
×
UNCOV
633
            id: null,
×
UNCOV
634
            name: currPrefix,
×
UNCOV
635
            bucket_id: object.bucket_id,
×
UNCOV
636
          })
×
UNCOV
637
          continue
×
UNCOV
638
        }
×
639

362✔
640
        delimitedResults.push(object)
362✔
641
      }
362✔
642
      searchResult = delimitedResults
67✔
643
    }
67✔
644

173✔
645
    let isTruncated = false
173✔
646

173✔
647
    if (searchResult.length > limit) {
173✔
648
      searchResult = searchResult.slice(0, limit)
152✔
649
      isTruncated = true
152✔
650
    }
152✔
651

173✔
652
    const folders: Obj[] = []
173✔
653
    const objects: Obj[] = []
173✔
654
    searchResult.forEach((obj) => {
173✔
655
      const target = obj.id === null ? folders : objects
831✔
656
      const name = obj.id === null && !obj.name.endsWith('/') ? obj.name + '/' : obj.name
831✔
657
      target.push({
831✔
658
        ...obj,
831✔
659
        name: options?.encodingType === 'url' ? encodeURIComponent(name) : name,
831!
660
      })
831✔
661
    })
173✔
662

173✔
663
    let nextContinuationToken: string | undefined
173✔
664
    if (isTruncated) {
173✔
665
      const sortColumn = (cursor?.sortColumn || options?.sortBy?.column) as
152✔
666
        | 'name'
152✔
667
        | 'created_at'
152✔
668
        | 'updated_at'
152✔
669
        | undefined
152✔
670

152✔
671
      nextContinuationToken = encodeContinuationToken({
152✔
672
        startAfter: searchResult[searchResult.length - 1].name,
152✔
673
        sortOrder: cursor?.sortOrder || options?.sortBy?.order,
152✔
674
        sortColumn,
152✔
675
        sortColumnAfter:
152✔
676
          sortColumn && sortColumn !== 'name' && searchResult[searchResult.length - 1][sortColumn]
152✔
677
            ? new Date(searchResult[searchResult.length - 1][sortColumn] || '').toISOString()
152!
678
            : undefined,
152✔
679
      })
152✔
680
    }
152✔
681

173✔
682
    return {
173✔
683
      hasNext: isTruncated,
173✔
684
      nextCursor: nextContinuationToken,
173✔
685
      folders: folders,
173✔
686
      objects: objects,
173✔
687
    }
173✔
688
  }
173✔
689

1✔
690
  /**
1✔
691
   * Generates a signed url for accessing an object securely
1✔
692
   * @param objectName
1✔
693
   * @param url
1✔
694
   * @param expiresIn seconds
1✔
695
   * @param metadata
1✔
696
   */
1✔
697
  async signObjectUrl(
1✔
698
    objectName: string,
5✔
699
    url: string,
5✔
700
    expiresIn: number,
5✔
701
    metadata?: Record<string, string | object | undefined>
5✔
702
  ) {
5✔
703
    await this.findObject(objectName)
5✔
704

2✔
705
    metadata = Object.keys(metadata || {}).reduce((all, key) => {
5!
706
      if (!all[key]) {
4✔
707
        delete all[key]
4✔
708
      }
4✔
709
      return all
4✔
710
    }, metadata || {})
5!
711

5✔
712
    // security-in-depth: as signObjectUrl could be used as a signing oracle,
5✔
713
    // make sure it's never able to specify a role JWT claim
5✔
714
    delete metadata['role']
5✔
715

5✔
716
    const urlParts = url.split('/')
5✔
717
    const urlToSign = decodeURI(urlParts.splice(3).join('/'))
5✔
718
    const { urlSigningKey } = await getJwtSecret(this.db.tenantId)
5✔
719
    const token = await signJWT({ url: urlToSign, ...metadata }, urlSigningKey, expiresIn)
2✔
720

2✔
721
    let urlPath = 'object'
2✔
722

2✔
723
    if (metadata?.transformations) {
5!
724
      urlPath = 'render/image'
×
725
    }
×
726

2✔
727
    // @todo parse the url properly
2✔
728
    return `/${urlPath}/sign/${urlToSign}?token=${token}`
2✔
729
  }
2✔
730

1✔
731
  /**
1✔
732
   * Generates multiple signed urls
1✔
733
   * @param paths
1✔
734
   * @param expiresIn
1✔
735
   */
1✔
736
  async signObjectUrls(paths: string[], expiresIn: number) {
1✔
737
    let results: { name: string }[] = []
4✔
738

4✔
739
    for (let i = 0; i < paths.length; ) {
4✔
740
      const pathsSubset = []
118✔
741
      let urlParamLength = 0
118✔
742

118✔
743
      for (; i < paths.length && urlParamLength < requestUrlLengthLimit; i++) {
118✔
744
        const path = paths[i]
30,004✔
745
        pathsSubset.push(path)
30,004✔
746
        urlParamLength += encodeURIComponent(path).length + 9 // length of '%22%2C%22'
30,004✔
747
      }
30,004✔
748

118✔
749
      const objects = await this.findObjects(pathsSubset, 'name')
118✔
750
      results = results.concat(objects)
118✔
751
    }
118✔
752

4✔
753
    const nameSet = new Set(results.map(({ name }) => name))
4✔
754

4✔
755
    const { urlSigningKey } = await getJwtSecret(this.db.tenantId)
4✔
756

4✔
757
    return Promise.all(
4✔
758
      paths.map(async (path) => {
4✔
759
        let error = null
30,004✔
760
        let signedURL = null
30,004✔
761
        if (nameSet.has(path)) {
30,004!
762
          const urlToSign = `${this.bucketId}/${path}`
×
763
          const token = await signJWT({ url: urlToSign }, urlSigningKey, expiresIn)
×
764
          signedURL = `/object/sign/${urlToSign}?token=${token}`
×
765
        } else {
30,004✔
766
          error = 'Either the object does not exist or you do not have access to it'
30,004✔
767
        }
30,004✔
768
        return {
30,004✔
769
          error,
30,004✔
770
          path,
30,004✔
771
          signedURL,
30,004✔
772
        }
30,004✔
773
      })
4✔
774
    )
4✔
775
  }
4✔
776

1✔
777
  /**
1✔
778
   * Generates a signed url for uploading an object
1✔
779
   * @param objectName
1✔
780
   * @param url
1✔
781
   * @param expiresIn seconds
1✔
782
   * @param owner
1✔
783
   * @param options
1✔
784
   */
1✔
785
  async signUploadObjectUrl(
1✔
786
    objectName: string,
6✔
787
    url: string,
6✔
788
    expiresIn: number,
6✔
789
    owner?: string,
6✔
790
    options?: { upsert?: boolean }
6✔
791
  ) {
6✔
792
    // check if user has INSERT permissions
6✔
793
    await this.uploader.canUpload({
6✔
794
      bucketId: this.bucketId,
6✔
795
      objectName,
6✔
796
      owner,
6✔
797
      isUpsert: options?.upsert ?? false,
6!
798
    })
6✔
799

3✔
800
    const { urlSigningKey } = await getJwtSecret(this.db.tenantId)
3✔
801
    const token = await signJWT(
3✔
802
      { owner, url, upsert: Boolean(options?.upsert) },
6✔
803
      urlSigningKey,
6✔
804
      expiresIn
6✔
805
    )
6✔
806

3✔
807
    return { url: `/object/upload/sign/${url}?token=${token}`, token }
3✔
808
  }
3✔
809

1✔
810
  /**
1✔
811
   * Verify the signature for a specific object
1✔
812
   * @param token
1✔
813
   * @param objectName
1✔
814
   */
1✔
815
  async verifyObjectSignature(token: string, objectName: string) {
1✔
816
    const { secret: jwtSecret, jwks } = await getJwtSecret(this.db.tenantId)
5✔
817

5✔
818
    let payload: SignedUploadToken
5✔
819
    try {
5✔
820
      payload = (await verifyJWT(token, jwtSecret, jwks)) as SignedUploadToken
5✔
821
    } catch (e) {
5✔
822
      const err = e as Error
2✔
823
      throw ERRORS.InvalidJWT(err)
2✔
824
    }
2✔
825

3✔
826
    const { url, exp } = payload
3✔
827

3✔
828
    if (url !== `${this.bucketId}/${objectName}`) {
5!
829
      throw ERRORS.InvalidSignature()
×
830
    }
×
831

3✔
832
    if (exp * 1000 < Date.now()) {
5!
833
      throw ERRORS.ExpiredSignature()
×
834
    }
×
835

3✔
836
    return payload
3✔
837
  }
3✔
838
}
1✔
839

1✔
840
interface ContinuationToken {
1✔
841
  startAfter: string
1✔
842
  sortOrder?: string // 'asc' | 'desc'
1✔
843
  sortColumn?: string
1✔
844
  sortColumnAfter?: string
1✔
845
}
1✔
846

1✔
847
const CONTINUATION_TOKEN_PART_MAP: Record<string, keyof ContinuationToken> = {
1✔
848
  l: 'startAfter',
1✔
849
  o: 'sortOrder',
1✔
850
  c: 'sortColumn',
1✔
851
  a: 'sortColumnAfter',
1✔
852
}
1✔
853

1✔
854
function encodeContinuationToken(tokenInfo: ContinuationToken) {
152✔
855
  let result = ''
152✔
856
  for (const [k, v] of Object.entries(CONTINUATION_TOKEN_PART_MAP)) {
152✔
857
    if (tokenInfo[v]) {
608✔
858
      result += `${k}:${tokenInfo[v]}\n`
524✔
859
    }
524✔
860
  }
608✔
861
  return Buffer.from(result.slice(0, -1)).toString('base64')
152✔
862
}
152✔
863

1✔
864
function decodeContinuationToken(token: string): ContinuationToken {
152✔
865
  const decodedParts = Buffer.from(token, 'base64').toString().split('\n')
152✔
866
  const result: ContinuationToken = {
152✔
867
    startAfter: '',
152✔
868
    sortOrder: 'asc',
152✔
869
  }
152✔
870
  for (const part of decodedParts) {
152✔
871
    const partMatch = part.match(/^(\S):(.*)/)
524✔
872
    if (!partMatch || partMatch.length !== 3 || !(partMatch[1] in CONTINUATION_TOKEN_PART_MAP)) {
524!
NEW
873
      throw new Error('Invalid continuation token')
×
NEW
874
    }
×
875
    result[CONTINUATION_TOKEN_PART_MAP[partMatch[1]]] = partMatch[2]
524✔
876
  }
524✔
877
  return result
152✔
878
}
152✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc