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

tomdesair / tus-java-server / 27465232708

13 Jun 2026 11:17AM UTC coverage: 92.468% (-2.1%) from 94.575%
27465232708

Pull #80

github

web-flow
Merge 3c0ac21fb into 73dcffedd
Pull Request #80: feat: Add upload lock contention resolution for HEAD requests

603 of 694 branches covered (86.89%)

Branch coverage included in aggregate %.

150 of 197 new or added lines in 7 files covered. (76.14%)

1 existing line in 1 file now uncovered.

1754 of 1855 relevant lines covered (94.56%)

5.88 hits per line

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

92.89
/src/main/java/me/desair/tus/server/TusFileUploadService.java
1
package me.desair.tus.server;
2

3
import jakarta.servlet.http.HttpServletRequest;
4
import jakarta.servlet.http.HttpServletResponse;
5
import java.io.File;
6
import java.io.IOException;
7
import java.io.InputStream;
8
import java.util.EnumSet;
9
import java.util.LinkedHashMap;
10
import java.util.LinkedHashSet;
11
import java.util.Set;
12
import me.desair.tus.server.checksum.ChecksumExtension;
13
import me.desair.tus.server.concatenation.ConcatenationExtension;
14
import me.desair.tus.server.core.CoreProtocol;
15
import me.desair.tus.server.creation.CreationExtension;
16
import me.desair.tus.server.download.DownloadExtension;
17
import me.desair.tus.server.exception.TusException;
18
import me.desair.tus.server.expiration.ExpirationExtension;
19
import me.desair.tus.server.termination.TerminationExtension;
20
import me.desair.tus.server.upload.UploadIdFactory;
21
import me.desair.tus.server.upload.UploadInfo;
22
import me.desair.tus.server.upload.UploadLock;
23
import me.desair.tus.server.upload.UploadLockingService;
24
import me.desair.tus.server.upload.UploadStorageService;
25
import me.desair.tus.server.upload.UuidUploadIdFactory;
26
import me.desair.tus.server.upload.cache.ThreadLocalCachedStorageAndLockingService;
27
import me.desair.tus.server.upload.disk.DiskLockingService;
28
import me.desair.tus.server.upload.disk.DiskStorageService;
29
import me.desair.tus.server.util.TusServletRequest;
30
import me.desair.tus.server.util.TusServletResponse;
31
import org.apache.commons.io.FileUtils;
32
import org.apache.commons.lang3.StringUtils;
33
import org.apache.commons.lang3.Validate;
34
import org.slf4j.Logger;
35
import org.slf4j.LoggerFactory;
36

37
/** Helper class that implements the server side tus v1.0.0 upload protocol */
38
public class TusFileUploadService {
39

40
  public static final String TUS_API_VERSION = "1.0.0";
41

42
  private static final Logger log = LoggerFactory.getLogger(TusFileUploadService.class);
4✔
43

44
  private UploadStorageService uploadStorageService;
45
  private UploadLockingService uploadLockingService;
46
  private UploadIdFactory idFactory = new UuidUploadIdFactory();
5✔
47
  private final LinkedHashMap<String, TusExtension> enabledFeatures = new LinkedHashMap<>();
5✔
48
  private final Set<HttpMethod> supportedHttpMethods = EnumSet.noneOf(HttpMethod.class);
4✔
49
  private boolean isThreadLocalCacheEnabled = false;
3✔
50
  private boolean isChunkedTransferDecodingEnabled = false;
3✔
51

52
  /** Constructor. */
53
  public TusFileUploadService() {
2✔
54
    String storagePath = FileUtils.getTempDirectoryPath() + File.separator + "tus";
4✔
55
    this.uploadStorageService = new DiskStorageService(idFactory, storagePath);
8✔
56
    this.uploadLockingService = new DiskLockingService(idFactory, storagePath);
8✔
57
    initFeatures();
2✔
58
  }
1✔
59

60
  protected void initFeatures() {
61
    // The order of the features is important
62
    addTusExtension(new CoreProtocol());
6✔
63
    addTusExtension(new CreationExtension());
6✔
64
    addTusExtension(new ChecksumExtension());
6✔
65
    addTusExtension(new TerminationExtension());
6✔
66
    addTusExtension(new ExpirationExtension());
6✔
67
    addTusExtension(new ConcatenationExtension());
6✔
68
  }
1✔
69

70
  /**
71
   * Set the URI under which the main tus upload endpoint is hosted. Optionally, this URI may
72
   * contain regex parameters in order to support endpoints that contain URL parameters, for example
73
   * /users/[0-9]+/files/upload
74
   *
75
   * @param uploadUri The URI of the main tus upload endpoint
76
   * @return The current service
77
   */
78
  public TusFileUploadService withUploadUri(String uploadUri) {
79
    this.idFactory.setUploadUri(uploadUri);
4✔
80
    return this;
2✔
81
  }
82

83
  /**
84
   * Specify the maximum number of bytes that can be uploaded per upload. If you don't call this
85
   * method, the maximum number of bytes is Long.MAX_VALUE.
86
   *
87
   * @param maxUploadSize The maximum upload length that is allowed
88
   * @return The current service
89
   */
90
  public TusFileUploadService withMaxUploadSize(Long maxUploadSize) {
91
    Validate.exclusiveBetween(
4✔
92
        0, Long.MAX_VALUE, maxUploadSize, "The max upload size must be bigger than 0");
2✔
93
    this.uploadStorageService.setMaxUploadSize(maxUploadSize);
4✔
94
    return this;
2✔
95
  }
96

97
  /**
98
   * Provide a custom {@link UploadIdFactory} implementation that should be used to generate
99
   * identifiers for the different uploads. Example implementation are {@link
100
   * me.desair.tus.server.upload.UuidUploadIdFactory} and {@link
101
   * me.desair.tus.server.upload.TimeBasedUploadIdFactory}.
102
   *
103
   * @param uploadIdFactory The custom {@link UploadIdFactory} implementation
104
   * @return The current service
105
   */
106
  public TusFileUploadService withUploadIdFactory(UploadIdFactory uploadIdFactory) {
107
    Validate.notNull(uploadIdFactory, "The UploadIdFactory cannot be null");
6✔
108
    String previousUploadUri = this.idFactory.getUploadUri();
4✔
109
    this.idFactory = uploadIdFactory;
3✔
110
    this.idFactory.setUploadUri(previousUploadUri);
4✔
111
    this.uploadStorageService.setIdFactory(this.idFactory);
5✔
112
    this.uploadLockingService.setIdFactory(this.idFactory);
5✔
113
    return this;
2✔
114
  }
115

116
  /**
117
   * Provide a custom {@link UploadStorageService} implementation that should be used to store
118
   * uploaded bytes and metadata ({@link UploadInfo}).
119
   *
120
   * @param uploadStorageService The custom {@link UploadStorageService} implementation
121
   * @return The current service
122
   */
123
  public TusFileUploadService withUploadStorageService(UploadStorageService uploadStorageService) {
124
    Validate.notNull(uploadStorageService, "The UploadStorageService cannot be null");
6✔
125
    // Copy over any previous configuration
126
    uploadStorageService.setMaxUploadSize(this.uploadStorageService.getMaxUploadSize());
6✔
127
    uploadStorageService.setUploadExpirationPeriod(
4✔
128
        this.uploadStorageService.getUploadExpirationPeriod());
1✔
129
    uploadStorageService.setIdFactory(this.idFactory);
4✔
130
    // Update the upload storage service
131
    this.uploadStorageService = uploadStorageService;
3✔
132
    prepareCacheIfEnabled();
2✔
133
    return this;
2✔
134
  }
135

136
  /**
137
   * Provide a custom {@link UploadLockingService} implementation that should be used when
138
   * processing uploads. The upload locking service is responsible for locking an upload that is
139
   * being processed so that it cannot be corrupted by simultaneous or delayed requests.
140
   *
141
   * @param uploadLockingService The {@link UploadLockingService} implementation to use
142
   * @return The current service
143
   */
144
  public TusFileUploadService withUploadLockingService(UploadLockingService uploadLockingService) {
145
    Validate.notNull(uploadLockingService, "The UploadStorageService cannot be null");
6✔
146
    uploadLockingService.setIdFactory(this.idFactory);
4✔
147
    // Update the upload storage service
148
    this.uploadLockingService = uploadLockingService;
3✔
149
    prepareCacheIfEnabled();
2✔
150
    return this;
2✔
151
  }
152

153
  /**
154
   * If you're using the default file system-based storage service, you can use this method to
155
   * specify the path where to store the uploaded bytes and upload information.
156
   *
157
   * @param storagePath The file system path where uploads can be stored (temporarily)
158
   * @return The current service
159
   */
160
  public TusFileUploadService withStoragePath(String storagePath) {
161
    Validate.notBlank(storagePath, "The storage path cannot be blank");
6✔
162
    withUploadStorageService(new DiskStorageService(storagePath));
7✔
163
    withUploadLockingService(new DiskLockingService(storagePath));
7✔
164
    prepareCacheIfEnabled();
2✔
165
    return this;
2✔
166
  }
167

168
  /**
169
   * Enable or disable a thread-local based cache of upload data. This can reduce the load on the
170
   * storage backends. By default this cache is disabled.
171
   *
172
   * @param isEnabled True if the cache should be enabled, false otherwise
173
   * @return The current service
174
   */
175
  public TusFileUploadService withThreadLocalCache(boolean isEnabled) {
176
    this.isThreadLocalCacheEnabled = isEnabled;
3✔
177
    prepareCacheIfEnabled();
2✔
178
    return this;
2✔
179
  }
180

181
  /**
182
   * Instruct this service to (not) decode any requests with Transfer-Encoding value "chunked". Use
183
   * this method in case the web container in which this service is running does not decode chunked
184
   * transfers itself. By default, chunked decoding is disabled.
185
   *
186
   * @param isEnabled True if chunked requests should be decoded, false otherwise.
187
   * @return The current service
188
   */
189
  public TusFileUploadService withChunkedTransferDecoding(boolean isEnabled) {
190
    isChunkedTransferDecodingEnabled = isEnabled;
3✔
191
    return this;
2✔
192
  }
193

194
  /**
195
   * You can set the number of milliseconds after which an upload is considered as expired and
196
   * available for cleanup.
197
   *
198
   * @param expirationPeriod The number of milliseconds after which an upload expires and can be
199
   *     removed
200
   * @return The current service
201
   */
202
  public TusFileUploadService withUploadExpirationPeriod(Long expirationPeriod) {
203
    uploadStorageService.setUploadExpirationPeriod(expirationPeriod);
4✔
204
    return this;
2✔
205
  }
206

207
  /**
208
   * Enable the unofficial `download` extension that also allows you to download uploaded bytes. By
209
   * default this feature is disabled.
210
   *
211
   * @return The current service
212
   */
213
  public TusFileUploadService withDownloadFeature() {
214
    addTusExtension(new DownloadExtension());
6✔
215
    return this;
2✔
216
  }
217

218
  /**
219
   * Enable or disable duplicate file processing based on checksum hash.
220
   *
221
   * @param isEnabled True if duplicate file processing should be enabled, false otherwise
222
   * @return The current service
223
   */
224
  public TusFileUploadService withUploadDeduplication(boolean isEnabled) {
225
    this.uploadStorageService.setUploadDeduplicationEnabled(isEnabled);
4✔
226
    return this;
2✔
227
  }
228

229
  /**
230
   * Add a custom (application-specific) extension that implements the {@link
231
   * me.desair.tus.server.TusExtension} interface. For example you can add your own extension that
232
   * checks authentication and authorization policies within your application for the user doing the
233
   * upload.
234
   *
235
   * @param feature The custom extension implementation
236
   * @return The current service
237
   */
238
  public TusFileUploadService addTusExtension(TusExtension feature) {
239
    Validate.notNull(feature, "A custom feature cannot be null");
6✔
240
    enabledFeatures.put(feature.getName(), feature);
7✔
241
    updateSupportedHttpMethods();
2✔
242
    return this;
2✔
243
  }
244

245
  /**
246
   * Disable the TusExtension for which the getName() method matches the provided string. The
247
   * default extensions have names "creation", "checksum", "expiration", "concatenation",
248
   * "termination" and "download". You cannot disable the "core" feature.
249
   *
250
   * @param extensionName The name of the extension to disable
251
   * @return The current service
252
   */
253
  public TusFileUploadService disableTusExtension(String extensionName) {
254
    Validate.notNull(extensionName, "The extension name cannot be null");
6✔
255
    Validate.isTrue(
3✔
256
        !StringUtils.equals("core", extensionName), "The core protocol cannot be disabled");
8✔
257

258
    enabledFeatures.remove(extensionName);
5✔
259
    updateSupportedHttpMethods();
2✔
260
    return this;
2✔
261
  }
262

263
  /**
264
   * Get all HTTP methods that are supported by this TusUploadService based on the enabled and/or
265
   * disabled tus extensions.
266
   *
267
   * @return The set of enabled HTTP methods
268
   */
269
  public Set<HttpMethod> getSupportedHttpMethods() {
270
    return EnumSet.copyOf(supportedHttpMethods);
4✔
271
  }
272

273
  /**
274
   * Get the set of enabled Tus extensions.
275
   *
276
   * @return The set of active extensions
277
   */
278
  public Set<String> getEnabledFeatures() {
279
    return new LinkedHashSet<>(enabledFeatures.keySet());
7✔
280
  }
281

282
  /**
283
   * Process a tus upload request. Use this method to process any request made to the main and sub
284
   * tus upload endpoints. This corresponds to the path specified in the withUploadUri() method and
285
   * any sub-path of that URI.
286
   *
287
   * @param servletRequest The {@link HttpServletRequest} of the request
288
   * @param servletResponse The {@link HttpServletResponse} of the request
289
   * @throws IOException When saving bytes or information of this requests fails
290
   */
291
  public void process(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
292
      throws IOException {
293
    process(servletRequest, servletResponse, null);
5✔
294
  }
1✔
295

296
  /**
297
   * Process a tus upload request that belongs to a specific owner. Use this method to process any
298
   * request made to the main and sub tus upload endpoints. This corresponds to the path specified
299
   * in the withUploadUri() method and any sub-path of that URI.
300
   *
301
   * @param servletRequest The {@link HttpServletRequest} of the request
302
   * @param servletResponse The {@link HttpServletResponse} of the request
303
   * @param ownerKey A unique identifier of the owner (group) of this upload
304
   * @throws IOException When saving bytes or information of this requests fails
305
   */
306
  public void process(
307
      HttpServletRequest servletRequest, HttpServletResponse servletResponse, String ownerKey)
308
      throws IOException {
309
    Validate.notNull(servletRequest, "The HTTP Servlet request cannot be null");
6✔
310
    Validate.notNull(servletResponse, "The HTTP Servlet response cannot be null");
6✔
311

312
    HttpMethod method = HttpMethod.getMethodIfSupported(servletRequest, supportedHttpMethods);
5✔
313

314
    log.debug(
5✔
315
        "Processing request with method {} and URL {}", method, servletRequest.getRequestURL());
1✔
316

317
    TusServletRequest request =
7✔
318
        new TusServletRequest(servletRequest, isChunkedTransferDecodingEnabled);
319
    request.setAttribute("me.desair.tus.uploadLockingService", uploadLockingService);
5✔
320
    TusServletResponse response = new TusServletResponse(servletResponse);
5✔
321

322
    try (UploadLock lock = acquireUploadLock(method, request.getRequestURI())) {
6✔
323

324
      processLockedRequest(method, request, response, ownerKey);
6✔
325

326
    } catch (TusException e) {
1✔
327
      log.error("Unable to lock upload for request URI " + request.getRequestURI(), e);
6✔
328
      response.sendError(e.getStatus(), e.getMessage());
6✔
329
    }
1✔
330
  }
1✔
331

332
  protected UploadLock acquireUploadLock(HttpMethod method, String requestUri)
333
      throws TusException, IOException {
334
    UploadLock lock = null;
2✔
335
    int retries = 0;
2✔
336
    while (retries < 25) {
3!
337
      try {
338
        lock = uploadLockingService.lockUploadByUri(requestUri);
5✔
339
        break;
1✔
340
      } catch (TusException e) {
1✔
341
        if (HttpMethod.HEAD.equals(method)) {
4✔
342
          uploadLockingService.requestLockRelease(requestUri);
4✔
343
          retries++;
1✔
344
          try {
345
            Thread.sleep(200L);
2✔
NEW
346
          } catch (InterruptedException ie) {
×
NEW
347
            Thread.currentThread().interrupt();
×
NEW
348
            throw new IOException("Lock acquisition retry interrupted", ie);
×
349
          }
1✔
350
        } else {
351
          throw e;
2✔
352
        }
353
      }
1✔
354
    }
355
    if (lock == null) {
2✔
356
      lock = uploadLockingService.lockUploadByUri(requestUri);
5✔
357
    }
358
    return lock;
2✔
359
  }
360

361
  /**
362
   * Method to retrieve the bytes that were uploaded to a specific upload URI.
363
   *
364
   * @param uploadUri The URI of the upload
365
   * @return An {@link InputStream} that will stream the uploaded bytes
366
   * @throws IOException When the retreiving the uploaded bytes fails
367
   * @throws TusException When the upload is still in progress or cannot be found
368
   */
369
  public InputStream getUploadedBytes(String uploadUri) throws IOException, TusException {
370
    return getUploadedBytes(uploadUri, null);
×
371
  }
372

373
  /**
374
   * Method to retrieve the bytes that were uploaded to a specific upload URI.
375
   *
376
   * @param uploadUri The URI of the upload
377
   * @param ownerKey The key of the owner of this upload
378
   * @return An {@link InputStream} that will stream the uploaded bytes
379
   * @throws IOException When the retreiving the uploaded bytes fails
380
   * @throws TusException When the upload is still in progress or cannot be found
381
   */
382
  public InputStream getUploadedBytes(String uploadUri, String ownerKey)
383
      throws IOException, TusException {
384

385
    try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadUri)) {
5✔
386

387
      return uploadStorageService.getUploadedBytes(uploadUri, ownerKey);
8✔
388
    }
389
  }
390

391
  /**
392
   * Get the information on the upload corresponding to the given upload URI.
393
   *
394
   * @param uploadUri The URI of the upload
395
   * @return Information on the upload
396
   * @throws IOException When retrieving the upload information fails
397
   * @throws TusException When the upload is still in progress or cannot be found
398
   */
399
  public UploadInfo getUploadInfo(String uploadUri) throws IOException, TusException {
400
    return getUploadInfo(uploadUri, null);
×
401
  }
402

403
  /**
404
   * Get the information on the upload corresponding to the given upload URI.
405
   *
406
   * @param uploadUri The URI of the upload
407
   * @param ownerKey The key of the owner of this upload
408
   * @return Information on the upload
409
   * @throws IOException When retrieving the upload information fails
410
   * @throws TusException When the upload is still in progress or cannot be found
411
   */
412
  public UploadInfo getUploadInfo(String uploadUri, String ownerKey)
413
      throws IOException, TusException {
414
    try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadUri)) {
5✔
415

416
      return uploadStorageService.getUploadInfo(uploadUri, ownerKey);
8✔
417
    }
418
  }
419

420
  /**
421
   * Method to delete an upload associated with the given upload URL. Invoke this method if you no
422
   * longer need the upload.
423
   *
424
   * @param uploadUri The upload URI
425
   */
426
  public void deleteUpload(String uploadUri) throws IOException, TusException {
427
    deleteUpload(uploadUri, null);
×
428
  }
×
429

430
  /**
431
   * Method to delete an upload associated with the given upload URL. Invoke this method if you no
432
   * longer need the upload.
433
   *
434
   * @param uploadUri The upload URI
435
   * @param ownerKey The key of the owner of this upload
436
   */
437
  public void deleteUpload(String uploadUri, String ownerKey) throws IOException, TusException {
438
    try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadUri)) {
5✔
439
      UploadInfo uploadInfo = uploadStorageService.getUploadInfo(uploadUri, ownerKey);
6✔
440
      if (uploadInfo != null) {
2!
441
        uploadStorageService.terminateUpload(uploadInfo);
4✔
442
      }
443
    }
444
  }
1✔
445

446
  /**
447
   * This method should be invoked periodically. It will cleanup any expired uploads and stale locks
448
   *
449
   * @throws IOException When cleaning fails
450
   */
451
  public void cleanup() throws IOException {
452
    uploadLockingService.cleanupStaleLocks();
3✔
453
    uploadStorageService.cleanupExpiredUploads(uploadLockingService);
5✔
454
  }
1✔
455

456
  protected void processLockedRequest(
457
      HttpMethod method, TusServletRequest request, TusServletResponse response, String ownerKey)
458
      throws IOException {
459
    try {
460
      validateRequest(method, request, ownerKey);
5✔
461

462
      executeProcessingByFeatures(method, request, response, ownerKey);
6✔
463

464
    } catch (TusException e) {
1✔
465
      processTusException(method, request, response, ownerKey, e);
7✔
466
    }
1✔
467
  }
1✔
468

469
  protected void executeProcessingByFeatures(
470
      HttpMethod method,
471
      TusServletRequest servletRequest,
472
      TusServletResponse servletResponse,
473
      String ownerKey)
474
      throws IOException, TusException {
475

476
    for (TusExtension feature : enabledFeatures.values()) {
12✔
477
      if (!servletRequest.isProcessedBy(feature)) {
4!
478
        servletRequest.addProcessor(feature);
3✔
479
        feature.process(method, servletRequest, servletResponse, uploadStorageService, ownerKey);
8✔
480
      }
481
    }
1✔
482
  }
1✔
483

484
  protected void validateRequest(
485
      HttpMethod method, HttpServletRequest servletRequest, String ownerKey)
486
      throws TusException, IOException {
487

488
    for (TusExtension feature : enabledFeatures.values()) {
12✔
489
      feature.validate(method, servletRequest, uploadStorageService, ownerKey);
7✔
490
    }
1✔
491
  }
1✔
492

493
  protected void processTusException(
494
      HttpMethod method,
495
      TusServletRequest request,
496
      TusServletResponse response,
497
      String ownerKey,
498
      TusException exception)
499
      throws IOException {
500

501
    int status = exception.getStatus();
3✔
502
    String message = exception.getMessage();
3✔
503

504
    log.warn(
12✔
505
        "Unable to process request {} {}. Sent response status {} with message \"{}\"",
506
        method,
507
        request.getRequestURL(),
5✔
508
        status,
6✔
509
        message);
510

511
    try {
512
      for (TusExtension feature : enabledFeatures.values()) {
12✔
513

514
        if (!request.isProcessedBy(feature)) {
4✔
515
          request.addProcessor(feature);
3✔
516
          feature.handleError(method, request, response, uploadStorageService, ownerKey);
8✔
517
        }
518
      }
1✔
519

520
      // Since an error occurred, the bytes we have written are probably not valid. So remove
521
      // them.
522
      UploadInfo uploadInfo = uploadStorageService.getUploadInfo(request.getRequestURI(), ownerKey);
7✔
523
      uploadStorageService.removeLastNumberOfBytes(uploadInfo, request.getBytesRead());
6✔
524

525
    } catch (TusException ex) {
×
526
      log.warn("An exception occurred while handling another exception", ex);
×
527
    }
1✔
528

529
    response.sendError(status, message);
4✔
530
  }
1✔
531

532
  private void updateSupportedHttpMethods() {
533
    supportedHttpMethods.clear();
3✔
534
    for (TusExtension tusFeature : enabledFeatures.values()) {
12✔
535
      supportedHttpMethods.addAll(tusFeature.getMinimalSupportedHttpMethods());
6✔
536
    }
1✔
537
  }
1✔
538

539
  private void prepareCacheIfEnabled() {
540
    if (isThreadLocalCacheEnabled && uploadStorageService != null && uploadLockingService != null) {
9!
541
      ThreadLocalCachedStorageAndLockingService service =
8✔
542
          new ThreadLocalCachedStorageAndLockingService(uploadStorageService, uploadLockingService);
543
      service.setIdFactory(this.idFactory);
4✔
544
      this.uploadStorageService = service;
3✔
545
      this.uploadLockingService = service;
3✔
546
    }
547
  }
1✔
548
}
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