├── .gitignore ├── Dockerfile ├── README.md ├── artifacts └── tus-server │ └── entrypoint.sh ├── configuration ├── application.properties ├── logback.xml └── tus-server-beans.xml ├── license.txt ├── pom.xml ├── redis.yml ├── src ├── main │ ├── assembly │ │ └── dir-descriptor.xml │ ├── bin │ │ ├── run.sh │ │ └── run_helper.sh │ ├── java │ │ └── com │ │ │ └── tus │ │ │ └── oss │ │ │ └── server │ │ │ ├── application │ │ │ ├── Application.java │ │ │ ├── SpringBootConfig.java │ │ │ └── SpringContextUtils.java │ │ │ ├── core │ │ │ ├── DeleteHandler.java │ │ │ ├── HeadHandler.java │ │ │ ├── OptionsHandler.java │ │ │ ├── PatchHandler.java │ │ │ ├── PostHandler.java │ │ │ ├── ServerVerticle.java │ │ │ ├── UploadInfo.java │ │ │ ├── UploadManager.java │ │ │ └── Utils.java │ │ │ ├── impl │ │ │ ├── RedisConfig.java │ │ │ ├── RedisUploadManager.java │ │ │ └── StoragePlugin.java │ │ │ └── openapi │ │ │ ├── AnnotationMappers.java │ │ │ ├── OpenApiRoutePublisher.java │ │ │ └── OpenApiSpecGenerator.java │ └── resources │ │ └── banner.txt └── test │ ├── java │ └── com │ │ └── tus │ │ └── oss │ │ └── server │ │ └── test │ │ └── SimpleUploadTest.java │ ├── partialsUpload.sh │ ├── simpleUpload.sh │ ├── toUpload.txt │ ├── toUploadChunk1.txt │ ├── toUploadChunk2.txt │ └── toUploadChunk3.txt └── tus-server.yml /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | **/artifacts/**/*.tar 4 | target/ 5 | # Ignore Mac DS_Store files 6 | .DS_Store 7 | **/logs/ 8 | ._.DS_Store 9 | .vertx -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.5-jdk-9-slim 2 | 3 | WORKDIR /usr/src/app 4 | COPY . /usr/src/app 5 | 6 | RUN mvn clean install 7 | 8 | FROM cantara/alpine-openjdk-jdk9 9 | RUN mkdir -p /opt/release/tus-server 10 | 11 | COPY --from=0 /usr/src/app/artifacts/tus-server/tus-server.tar /opt/release/ 12 | RUN tar xvf /opt/release/tus-server.tar -C /opt/release/tus-server 13 | ADD ./artifacts/tus-server/entrypoint.sh /opt/release/tus-server/bin 14 | RUN chmod +x /opt/release/tus-server/bin/entrypoint.sh 15 | CMD ["/opt/release/tus-server/bin/entrypoint.sh"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TUS Server Java Implementation 2 | An implementation of the Tus Resumable Upload protocol [https://tus.io/protocols/resumable-upload.html] in java. Vertx-Web is used for the http stack part 3 | and redis as the backend for upload information management. 4 | Extensions supported are creation,checksum,termination,concatenation. The purpose of this repository is to provide a Tus protocol implementation agnostic 5 | of the underlying storage provider thus allowing implementors to focus on their business logic and specific needs. 6 | An issue that is not a concern of the protocol but could be an extension is the handling of the locks during a patch operation. So when a patch is initiated 7 | a lock is acquired (for most obvious cases) to ensure consistency. If the process triggered from patch fails and lock is not released there is a 8 | phantom lock remaining. This edge case could be mitigated by either making the server sticky and keeping the locks in-process or by perhaps issuing a 9 | release request with a lock token obtained by the initiator of the upload. 10 | No authentication valves are implemented here also. 11 | 12 | # Instructions 13 | To just build the tus-server-implementation just: 14 | 15 | ```cd ``` 16 | 17 | ```mvn clean install``` 18 | 19 | To build the docker image for the tus-server-implementation just (docker edge release [https://docs.docker.com/edge/] is required!): 20 | 21 | ```cd ``` 22 | 23 | ```docker build -t tus_server .``` 24 | 25 | In order to run tus-server-implementation along with all dependencies just (assuming you already have the docker image built from above): 26 | 27 | ```cd ``` 28 | 29 | ```docker-compose -f tus-server.yml up``` 30 | 31 | In order to setup the tus-server-implementation for development from your favorite IDE redis running is required: 32 | 33 | ```cd ``` 34 | 35 | ```docker-compose -f redis.yml up``` 36 | 37 | After that in order to run the tus-server-implementation from inside your favorite IDE just: 38 | Run the com.tus.oss.server.application.Application main class 39 | with program arguments: 40 | -c /configuration/ -b tus-server-beans.xml 41 | and VM parameters: -Dlogging.config=file:/configuration/logback.xml 42 | Redis must be live also (see above how to run it) 43 | 44 | # Testing 45 | In the test folder: 46 | There is a very simple upload test that uses tus-java-client [https://github.com/tus/tus-java-client]. 47 | There are also curl tests that test simple upload and partial uploads (concatenation extension) 48 | -------------------------------------------------------------------------------- /artifacts/tus-server/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | set -o xtrace 4 | source $(dirname "$(readlink -f "$0")")/run_helper.sh 5 | printenv 6 | run_me $1 -------------------------------------------------------------------------------- /configuration/application.properties: -------------------------------------------------------------------------------- 1 | host=${HOST_IP:0.0.0.0} 2 | port=${HOST_PORT:6969} 3 | TusExtensions=creation,checksum,termination,concatenation 4 | TusMaxSize=1073741824 5 | TusResumable=1.0.0 6 | TusVersion=1.0.0 7 | basePath=http://${host}:${port} 8 | contextPath=/uploads/ 9 | TusChecksumAlgorithms=sha1 10 | redis.host=${REDIS_IP:0.0.0.0} 11 | redis.port=${REDIS_PORT:6379} -------------------------------------------------------------------------------- /configuration/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%20.20thread] %-5level %logger{5} - %msg%n 5 | 6 | 7 | 8 | 9 | logs/tus-server.log 10 | 11 | %d{yyyy-MM-dd_HH:mm:ss.SSS} [%20.20thread] %-5level %logger{36} - %msg%n 12 | 13 | 14 | 15 | logs/us-server.%i.log.zip 16 | 1 17 | 10 18 | 19 | 20 | 21 | 5MB 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /configuration/tus-server-beans.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 by Christos Karatzas 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.tus.oss 8 | tus-server-implementation 9 | 1.0-SNAPSHOT 10 | 11 | 12 | 13 | 14 | false 15 | 16 | central 17 | bintray 18 | http://jcenter.bintray.com 19 | 20 | 21 | 22 | 23 | UTF-8 24 | 1.9 25 | 26 | 3.5.0 27 | 5.0.2.RELEASE 28 | 1.5.9.RELEASE 29 | 30 | 4.4.1.Final 31 | 32 | 1.7.7 33 | 1.1.6 34 | 2.0.0-rc3 35 | 36 | 37 | 38 | 39 | 40 | 41 | org.apache.maven.plugins 42 | maven-compiler-plugin 43 | 3.7.0 44 | 45 | ${java.version} 46 | ${java.version} 47 | 48 | 49 | 50 | maven-assembly-plugin 51 | 52 | 53 | generate-tar-ball 54 | 55 | 56 | assembly-app 57 | package 58 | 59 | single 60 | 61 | 62 | false 63 | tus-server 64 | 65 | src/main/assembly/dir-descriptor.xml 66 | 67 | 68 | 69 | 70 | 71 | 72 | maven-antrun-plugin 73 | 74 | 75 | copy-to-artifacts 76 | install 77 | 78 | 79 | 81 | 82 | 83 | 84 | 85 | run 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | io.swagger.core.v3 97 | swagger-core 98 | ${swagger.version} 99 | 100 | 101 | biz.paluch.redis 102 | lettuce 103 | ${redis.lettuce} 104 | 105 | 106 | com.google.code.gson 107 | gson 108 | 2.3.1 109 | 110 | 111 | io.vertx 112 | vertx-web 113 | ${vertx.version} 114 | 115 | 116 | commons-cli 117 | commons-cli 118 | 1.2 119 | 120 | 121 | 122 | javax.inject 123 | javax.inject 124 | 1 125 | 126 | 127 | org.springframework 128 | spring-core 129 | ${spring.version} 130 | 131 | 132 | org.springframework 133 | spring-context 134 | ${spring.version} 135 | 136 | 137 | org.springframework 138 | spring-web 139 | ${spring.version} 140 | 141 | 142 | org.springframework.boot 143 | spring-boot 144 | ${spring.boot.version} 145 | 146 | 147 | 148 | org.slf4j 149 | slf4j-api 150 | ${slf4j.version} 151 | 152 | 153 | ch.qos.logback 154 | logback-classic 155 | ${logback.version} 156 | 157 | 158 | ch.qos.logback 159 | logback-core 160 | ${logback.version} 161 | 162 | 163 | 164 | io.tus.java.client 165 | tus-java-client 166 | 0.3.2 167 | test 168 | 169 | 170 | 171 | -------------------------------------------------------------------------------- /redis.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | redis: 4 | image: redis 5 | container_name: redis 6 | ports: 7 | - "6379:6379" -------------------------------------------------------------------------------- /src/main/assembly/dir-descriptor.xml: -------------------------------------------------------------------------------- 1 | 5 | com.tus.oss.server:application 6 | 7 | tar 8 | 9 | 10 | false 11 | 12 | 13 | 14 | lib 15 | 16 | 17 | 18 | 19 | 20 | src/main/bin 21 | bin 22 | 777 23 | 711 24 | 25 | 26 | ./configuration/ 27 | config 28 | 29 | 30 | src/main/assembly 31 | logs 32 | 33 | * 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/main/bin/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | source $(dirname "$(readlink -f "$0")")/run_helper.sh 5 | 6 | java_pid= 7 | 8 | stop () { 9 | [ "$java_pid" ] && kill $java_pid 10 | exit 11 | } 12 | 13 | bounce () { 14 | [ "$java_pid" ] && kill $java_pid 15 | trap bounce HUP 16 | } 17 | 18 | trap stop INT TERM 19 | trap bounce HUP 20 | 21 | while : ; do 22 | 23 | run_me "$1" & 24 | java_pid=$! 25 | 26 | wait $java_pid 27 | 28 | done -------------------------------------------------------------------------------- /src/main/bin/run_helper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | APPLICATION=tus.oss.server 4 | 5 | APPLICATION_HOME=$( cd $(dirname $0)/.. && pwd ) 6 | APPLICATION_CFG_DIR=$APPLICATION_HOME/config 7 | APPLICATION_LOGS=$APPLICATION_HOME/logs 8 | SPRING_CONFIG=tus-server-beans.xml 9 | 10 | JVM_ARGS="\ 11 | -Xms1g\ 12 | -Xmx1g\ 13 | -XX:+UseConcMarkSweepGC\ 14 | -XX:+UseParNewGC\ 15 | -Xloggc:${APPLICATION_LOGS}/gc.log.$(date +%Y%m%d_%H%M%S)\ 16 | -verbose:gc\ 17 | -XX:+PrintGCDetails\ 18 | -XX:+UnlockDiagnosticVMOptions\ 19 | -XX:+LogVMOutput\ 20 | -XX:LogFile=${APPLICATION_LOGS}/jvm.log.$(date +%Y%m%d_%H%M%S)\ 21 | -XX:-OmitStackTraceInFastThrow\ 22 | -Dcom.sun.management.jmxremote.port=20000\ 23 | -Dcom.sun.management.jmxremote.authenticate=false\ 24 | -Dcom.sun.management.jmxremote.ssl=false\ 25 | $JVM_ARGS" 26 | 27 | 28 | JVM_ARGS="$JVM_ARGS\ 29 | -Dlogback.configurationFile=${APPLICATION_CFG_DIR}/logback.xml\ 30 | -Dlogging.config=file:${APPLICATION_CFG_DIR}/logback.xml" 31 | 32 | for props in application.properties 33 | 34 | do 35 | [ -f $APPLICATION_CFG_DIR/$props ] && PROPS_ARGS="$PROPS_ARGS -p $props" 36 | done 37 | 38 | [ "$SPRING_CONFIG" ] && SPRING_ARGS="-b $SPRING_CONFIG" 39 | 40 | MAIN_CLASS=com.$APPLICATION.application.Application 41 | 42 | cd $APPLICATION_HOME 43 | 44 | run_me() { 45 | exec java -cp "$APPLICATION_HOME/lib/*" $JVM_ARGS $MAIN_CLASS -c $APPLICATION_CFG_DIR $PROPS_ARGS $SPRING_ARGS 46 | } -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/application/Application.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.application; 2 | 3 | import com.tus.oss.server.core.ServerVerticle; 4 | import io.vertx.core.Vertx; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.context.ApplicationContext; 8 | 9 | import java.util.Optional; 10 | 11 | /** 12 | * @author ckaratza 13 | * The entry point of the Tus Server Implementation. Loads all configuration, creates application context and starts Tus Server Verticle. 14 | */ 15 | public class Application { 16 | 17 | private static final Logger log = LoggerFactory.getLogger(Application.class); 18 | 19 | public static void main(String[] args) { 20 | SpringBootConfig bootConfig = SpringBootConfig.fromCommandLineArgs(args, Optional.empty()); 21 | ApplicationContext ctx = SpringContextUtils.bootSpringApplication(bootConfig); 22 | ServerVerticle serverVerticle = ctx.getBean(ServerVerticle.class); 23 | Vertx.vertx().deployVerticle(serverVerticle); 24 | serverVerticle.start(); 25 | log.info("Tus Server started..."); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/application/SpringBootConfig.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.application; 2 | 3 | import org.apache.commons.cli.*; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.util.Assert; 7 | 8 | import java.io.File; 9 | import java.util.Arrays; 10 | import java.util.Collections; 11 | import java.util.List; 12 | import java.util.Optional; 13 | import java.util.stream.Collectors; 14 | 15 | /** 16 | * @author ckaratza 17 | * This class hold configuration required to boot a new Spring application. 18 | */ 19 | class SpringBootConfig { 20 | 21 | private static final Logger logger = LoggerFactory.getLogger(SpringBootConfig.class); 22 | 23 | private static final CommandLineParser defaultCommandLineParser = new PosixParser(); 24 | private static final Options defaultCommandLineOptions; 25 | private final static String DEFAULT_CONFIG_DIR = "config"; 26 | private final static String DEFAULT_PROPS_FILE = "application.properties"; 27 | private final static String DEFAULT_CUSTOM_BEANS = "custom.beans.xml"; 28 | 29 | static { 30 | defaultCommandLineOptions = new Options(); 31 | defaultCommandLineOptions.addOption("c", "configuration", true, "Configuration directory"); 32 | defaultCommandLineOptions.addOption("p", "properties", true, "Properties file"); 33 | defaultCommandLineOptions.addOption("b", "spring-configuration", true, "Custom Spring configuration file"); 34 | } 35 | 36 | private final String[] appArgs; 37 | private final File configDir; 38 | private final List propsFiles; 39 | private final List springConfigFiles; 40 | 41 | /** 42 | * @param appArgs pass through application arguments 43 | * @param configDir the directory that contains the configuration files for the application 44 | * @param propsFiles the properties files 45 | * @param springConfigFiles the spring configuration files 46 | */ 47 | private SpringBootConfig(String[] appArgs, 48 | String configDir, 49 | List propsFiles, 50 | List springConfigFiles) { 51 | 52 | this.appArgs = appArgs; 53 | this.springConfigFiles = Collections.unmodifiableList(springConfigFiles); 54 | this.configDir = new File(configDir.trim()); 55 | 56 | if (!this.configDir.exists() || !this.configDir.isDirectory()) { 57 | throw new IllegalArgumentException(String.format("Bad config directory: %s.", this.configDir.getAbsolutePath())); 58 | } 59 | 60 | this.propsFiles = propsFiles.stream().map( 61 | p -> new File(this.configDir, p.trim())).peek(f -> { 62 | if (!f.exists() || !f.canRead()) { 63 | throw new IllegalArgumentException(String.format("Bad properties file: %s.", f.getAbsolutePath())); 64 | } 65 | } 66 | ).collect(Collectors.toList()); 67 | 68 | logger.info("Config directory: {}.", this.configDir); 69 | logger.info("Properties files: {}.", propsFiles); 70 | logger.info("Spring config files: {}.", springConfigFiles); 71 | } 72 | 73 | static SpringBootConfig fromCommandLineArgs(String[] args, Optional defaultBeans) { 74 | logger.debug("fromCommandLineArgs: args = {}. Default beans {}.", Arrays.asList(args), defaultBeans); 75 | /* Parse command line arguments */ 76 | CommandLine cmd; 77 | try { 78 | cmd = defaultCommandLineParser.parse(defaultCommandLineOptions, args, true); 79 | } catch (ParseException e) { 80 | logger.error("Error while parsing application arguments: {}.", Arrays.asList(args)); 81 | throw new IllegalArgumentException(e); 82 | } 83 | 84 | String configDir = cmd.getOptionValue("c", DEFAULT_CONFIG_DIR); 85 | String customBeans = cmd.getOptionValue("b", DEFAULT_CUSTOM_BEANS); 86 | 87 | File customBeansFile = new File(configDir, customBeans); 88 | 89 | List springConfigFiles = customBeansFile.exists() 90 | ? Collections.singletonList("file://" + customBeansFile.getAbsolutePath()) : Collections.emptyList(); 91 | defaultBeans.ifPresent(springConfigFiles::add); 92 | Assert.isTrue(!springConfigFiles.isEmpty(), "No spring beans provided!"); 93 | 94 | List propertiesFiles = cmd.hasOption("p") 95 | ? Arrays.asList(cmd.getOptionValues("p")) 96 | : Collections.singletonList(DEFAULT_PROPS_FILE); 97 | 98 | return new SpringBootConfig(cmd.getArgs(), configDir, propertiesFiles, springConfigFiles); 99 | } 100 | 101 | List getPropsFiles() { 102 | return this.propsFiles; 103 | } 104 | 105 | List getSpringConfigFiles() { 106 | return this.springConfigFiles; 107 | } 108 | 109 | String[] getAppArgs() { 110 | return this.appArgs; 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/application/SpringContextUtils.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.application; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.boot.SpringApplication; 6 | import org.springframework.context.ApplicationContext; 7 | import org.springframework.context.ConfigurableApplicationContext; 8 | import org.springframework.core.env.ConfigurableEnvironment; 9 | import org.springframework.core.env.MutablePropertySources; 10 | import org.springframework.core.env.PropertiesPropertySource; 11 | import org.springframework.core.env.StandardEnvironment; 12 | import org.springframework.core.io.FileSystemResource; 13 | import org.springframework.core.io.support.PropertiesLoaderUtils; 14 | 15 | import java.io.IOException; 16 | import java.util.Objects; 17 | 18 | /** 19 | * @author ckaratza 20 | * A helper class for creating the spring application context. 21 | */ 22 | final class SpringContextUtils { 23 | 24 | private static final Logger logger = LoggerFactory.getLogger(SpringContextUtils.class); 25 | 26 | 27 | static ApplicationContext bootSpringApplication(SpringBootConfig bootConfig) { 28 | ConfigurableEnvironment env = new StandardEnvironment(); 29 | MutablePropertySources propertySources = env.getPropertySources(); 30 | bootConfig.getPropsFiles().stream() 31 | .map(f -> { 32 | try { 33 | return new PropertiesPropertySource(f.toString(), PropertiesLoaderUtils.loadProperties(new FileSystemResource(f))); 34 | } catch (IOException e) { 35 | logger.error("Failed to load {}:{}.", f, e); 36 | return null; 37 | } 38 | }) 39 | .filter(Objects::nonNull) 40 | .forEach(propertySources::addFirst); 41 | SpringApplication app = new SpringApplication(bootConfig.getSpringConfigFiles().toArray(new Object[bootConfig.getSpringConfigFiles().size()])); 42 | app.setLogStartupInfo(true); 43 | app.setMainApplicationClass(bootConfig.getClass()); 44 | app.setEnvironment(env); 45 | app.setRegisterShutdownHook(true); 46 | ConfigurableApplicationContext applicationContext = app.run(bootConfig.getAppArgs()); 47 | applicationContext.registerShutdownHook(); 48 | return applicationContext; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/DeleteHandler.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.Parameter; 5 | import io.swagger.v3.oas.annotations.enums.ParameterIn; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 8 | import io.vertx.core.http.HttpServerResponse; 9 | import io.vertx.ext.web.RoutingContext; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | import org.springframework.beans.factory.annotation.Value; 13 | import org.springframework.stereotype.Component; 14 | 15 | import javax.inject.Inject; 16 | 17 | /** 18 | * @author ckaratza 19 | * The termination extension handler. 20 | */ 21 | @Component 22 | public class DeleteHandler { 23 | 24 | private static final Logger log = LoggerFactory.getLogger(DeleteHandler.class); 25 | 26 | private final String tusResumable; 27 | private final UploadManager uploadManager; 28 | 29 | @Inject 30 | public DeleteHandler(@Value("${TusResumable}") String tusResumable, UploadManager uploadManager) { 31 | this.tusResumable = tusResumable; 32 | this.uploadManager = uploadManager; 33 | } 34 | 35 | @Operation(summary = "Deletes a specific upload.", method = "DELETE", 36 | responses = {@ApiResponse(responseCode = "204", description = "Request processed")}, 37 | parameters = {@Parameter(in = ParameterIn.PATH, name = "uploadID", 38 | required = true, description = "The ID of the upload unit of work", 39 | schema = @Schema(type = "string", format= "uuid"))}) 40 | void handleRequest(RoutingContext ctx) { 41 | String uploadID = ctx.request().getParam("uploadID"); 42 | HttpServerResponse response = ctx.response(); 43 | boolean deleted = uploadManager.discardUpload(uploadID); 44 | log.info("UploadID deleted {}.", deleted); 45 | response.setStatusCode(204); 46 | response.putHeader("Tus-Resumable", tusResumable); 47 | response.end(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/HeadHandler.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.Parameter; 5 | import io.swagger.v3.oas.annotations.enums.ParameterIn; 6 | import io.swagger.v3.oas.annotations.headers.Header; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import io.vertx.core.http.HttpServerResponse; 10 | import io.vertx.ext.web.RoutingContext; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.beans.factory.annotation.Value; 14 | import org.springframework.stereotype.Component; 15 | 16 | import javax.inject.Inject; 17 | import java.util.Optional; 18 | 19 | /** 20 | * @author ckaratza 21 | * Return all the information regarding an upload. 22 | */ 23 | @Component 24 | public class HeadHandler { 25 | 26 | private static final Logger log = LoggerFactory.getLogger(HeadHandler.class); 27 | 28 | private final String tusResumable; 29 | private final UploadManager uploadManager; 30 | 31 | @Inject 32 | public HeadHandler(@Value("${TusResumable}") String tusResumable, UploadManager uploadManager) { 33 | this.tusResumable = tusResumable; 34 | this.uploadManager = uploadManager; 35 | } 36 | 37 | @Operation(summary = "Provides status information for a specific upload.", method = "HEAD", 38 | responses = { 39 | @ApiResponse(responseCode = "404", description = "Upload unit of work not found."), 40 | @ApiResponse(responseCode = "200", description = "Upload unit of work found.", 41 | headers = {@Header(name = "Upload-Length", description = "The total length of the upload unit of work.", required = true), 42 | @Header(name = "Upload-Offset", description = "How many bytes have been uploaded so far.", required = true)})}, 43 | parameters = {@Parameter(in = ParameterIn.PATH, name = "uploadID", 44 | required = true, description = "The ID of the upload unit of work", schema = @Schema(type = "string", format = "uuid"))}) 45 | void handleRequest(RoutingContext ctx) { 46 | String uploadID = ctx.request().getParam("uploadID"); 47 | HttpServerResponse response = ctx.response(); 48 | uploadManager.findUploadInfo(uploadID).or(() -> { 49 | response.setStatusCode(404); 50 | return Optional.empty(); 51 | }).ifPresent(info -> { 52 | response.putHeader("Cache-Control", "no-store"); 53 | response.putHeader("Upload-Length", Long.toString(info.getEntityLength())); 54 | response.putHeader("Upload-Offset", Long.toString(info.getOffset())); 55 | if (info.getMetadata() != null) response.putHeader("Upload-Metadata", info.getMetadata()); 56 | if (info.isPartial()) response.putHeader("Upload-Concat", "partial"); 57 | else if (info.getUploadConcatMergedValue() != null) 58 | response.putHeader("Upload-Concat", info.getUploadConcatMergedValue()); 59 | response.setStatusCode(200); 60 | }); 61 | response.putHeader("Tus-Resumable", tusResumable); 62 | response.end(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/OptionsHandler.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.headers.Header; 5 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 6 | import io.vertx.core.http.HttpServerResponse; 7 | import io.vertx.ext.web.RoutingContext; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.stereotype.Component; 10 | 11 | import javax.inject.Inject; 12 | 13 | /** 14 | * @author ckaratza 15 | * The very simple options call that reveals Tus Server information. 16 | */ 17 | @Component 18 | public class OptionsHandler { 19 | 20 | private final String tusResumable; 21 | private final String tusVersion; 22 | private final String tusMaxSize; 23 | private final String tusExtensions; 24 | private final String tusChecksumAlgorithms; 25 | 26 | @Inject 27 | public OptionsHandler(@Value("${TusResumable}") String tusResumable, @Value("${TusVersion}") String tusVersion, 28 | @Value("${TusChecksumAlgorithms}") String tusChecksumAlgorithms, 29 | @Value("${TusMaxSize}") String tusMaxSize, @Value("${TusExtensions}") String tusExtensions) { 30 | this.tusResumable = tusResumable; 31 | this.tusVersion = tusVersion; 32 | this.tusMaxSize = tusMaxSize; 33 | this.tusExtensions = tusExtensions; 34 | this.tusChecksumAlgorithms = tusChecksumAlgorithms; 35 | } 36 | 37 | @Operation(summary = "Provides information about the server implementation of the Tus.io protocol.", method = "OPTIONS", 38 | responses = { 39 | @ApiResponse(responseCode = "204", description = "Server Information.", 40 | headers = {@Header(name = "Tus-Version", description = "The versions of Tus.io protocol supported.", required = true), 41 | @Header(name = "Tus-Max-Size", description = "The maximum length server allows to be uploaded.", required = true), 42 | @Header(name = "Tus-Extension", description = "Tus.io extensions currently supported.", required = true), 43 | @Header(name = "Tus-Checksum-Algorithm", description = "Tus.io checksum algorithms currently supported.", required = true)})}) 44 | void handleRequest(RoutingContext ctx) { 45 | HttpServerResponse response = ctx.response(); 46 | response.putHeader("Tus-Resumable", tusResumable); 47 | response.putHeader("Tus-Version", tusVersion); 48 | response.putHeader("Tus-Max-Size", tusMaxSize); 49 | response.putHeader("Tus-Extension", tusExtensions); 50 | response.putHeader("Tus-Checksum-Algorithm", tusChecksumAlgorithms); 51 | response.setStatusCode(204); 52 | response.end(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/PatchHandler.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.Parameter; 5 | import io.swagger.v3.oas.annotations.enums.ParameterIn; 6 | import io.swagger.v3.oas.annotations.headers.Header; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import io.vertx.core.http.HttpServerResponse; 10 | import io.vertx.ext.web.RoutingContext; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.beans.factory.annotation.Value; 14 | import org.springframework.stereotype.Component; 15 | 16 | import javax.inject.Inject; 17 | import java.util.Optional; 18 | 19 | import static com.tus.oss.server.core.Utils.*; 20 | 21 | /** 22 | * @author ckaratza 23 | * The patch method handling that performs sanity checks on the request and delegates to a storage plugin. 24 | * The storage call is executed in a blocking context in order not to block vertx event loop. 25 | * It also handles checksum extension. 26 | */ 27 | @Component 28 | public class PatchHandler { 29 | 30 | private static final Logger log = LoggerFactory.getLogger(PatchHandler.class); 31 | 32 | private final String tusResumable; 33 | private final UploadManager uploadManager; 34 | 35 | @Inject 36 | public PatchHandler(@Value("${TusResumable}") String tusResumable, UploadManager uploadManager) { 37 | this.tusResumable = tusResumable; 38 | this.uploadManager = uploadManager; 39 | } 40 | 41 | @Operation(summary = "Adds bytes to a specific upload.", method = "PATCH", 42 | responses = { 43 | @ApiResponse(responseCode = "400", description = "Bad Request."), 44 | @ApiResponse(responseCode = "423", description = "Upload unit of work currently in process."), 45 | @ApiResponse(responseCode = "409", description = "Offset mismatch."), 46 | @ApiResponse(responseCode = "404", description = "Upload unit of work not found."), 47 | @ApiResponse(responseCode = "204", description = "Bytes processed.", 48 | headers = {@Header(name = "Upload-Offset", description = "How many bytes have been uploaded so far.", required = true)})}, 49 | parameters = {@Parameter(in = ParameterIn.PATH, name = "uploadID", 50 | required = true, description = "The ID of the upload unit of work", schema = @Schema(type = "string", format = "uuid")), 51 | @Parameter(name = "Upload-Offset", in = ParameterIn.HEADER, required = true, schema = @Schema(type = "integer")), 52 | @Parameter(name = "Content-Length", in = ParameterIn.HEADER, required = true, schema = @Schema(type = "integer")), 53 | @Parameter(name = "Content-Type", example = "application/offset+octet-stream", required = true, schema = @Schema(type = "string")), 54 | @Parameter(name = "Upload-Checksum", schema = @Schema(type = "string"))}) 55 | void handleRequestForPatch(RoutingContext ctx) { 56 | handleRequest(ctx); 57 | } 58 | 59 | @Operation(summary = "Adds bytes to a specific upload.", method = "POST", 60 | responses = { 61 | @ApiResponse(responseCode = "400", description = "Bad Request."), 62 | @ApiResponse(responseCode = "423", description = "Upload unit of work currently in process."), 63 | @ApiResponse(responseCode = "409", description = "Offset mismatch."), 64 | @ApiResponse(responseCode = "404", description = "Upload unit of work not found."), 65 | @ApiResponse(responseCode = "204", description = "Bytes processed.", 66 | headers = {@Header(name = "Upload-Offset", description = "How many bytes have been uploaded so far.", required = true)})}, 67 | parameters = {@Parameter(in = ParameterIn.PATH, name = "uploadID", 68 | required = true, description = "The ID of the upload unit of work", schema = @Schema(type = "string", format = "uuid")), 69 | @Parameter(name = "Upload-Offset", in = ParameterIn.HEADER, required = true, schema = @Schema(type = "integer")), 70 | @Parameter(name = "Content-Length", in = ParameterIn.HEADER, required = true, schema = @Schema(type = "integer")), 71 | @Parameter(name = "Content-Type", example = "application/offset+octet-stream", required = true, schema = @Schema(type = "string")), 72 | @Parameter(name = "Upload-Checksum", schema = @Schema(type = "string"))}) 73 | void handleRequestForPost(RoutingContext ctx) { 74 | handleRequest(ctx); 75 | } 76 | 77 | private void handleRequest(RoutingContext ctx) { 78 | log.info("In to PATCH Handler {}.", ctx); 79 | HttpServerResponse response = ctx.response(); 80 | String uploadID = ctx.request().getParam("uploadID"); 81 | Optional contentType = getHeaderAsString("Content-Type", ctx); 82 | Optional offset = getHeaderAsLong("Upload-Offset", ctx); 83 | Optional contentLength = getHeaderAsLong("Content-Length", ctx); 84 | Optional checksumInfo = getHeaderAsChecksumInfo("Upload-Checksum", ctx); 85 | Optional uploadInfo = uploadManager.findUploadInfo(uploadID); 86 | boolean rogueRequest = false; 87 | if (!contentType.isPresent() || !"application/offset+octet-stream".equals(contentType.get())) { 88 | rogueRequest = true; 89 | response.setStatusCode(400); 90 | } 91 | if (!uploadInfo.isPresent()) { 92 | rogueRequest = true; 93 | response.setStatusCode(400); 94 | } 95 | if (!offset.isPresent() || offset.get() < 0) { 96 | rogueRequest = true; 97 | response.setStatusCode(400); 98 | } 99 | if (!rogueRequest) { 100 | if (!uploadManager.acquireLock(uploadID)) { 101 | response.setStatusCode(423); 102 | } else { 103 | UploadInfo info = uploadInfo.get(); 104 | if (offset.get() != info.getOffset() && checkContentLengthWithCurrentOffset(contentLength, offset.get(), info.getEntityLength())) { 105 | response.setStatusCode(409); 106 | } else { 107 | ctx.vertx().executeBlocking(future -> { 108 | try { 109 | long persisted = uploadManager.delegateToStoragePlugin(ctx.request(), uploadID, offset.get(), checksumInfo); 110 | future.complete(persisted); 111 | } finally { 112 | uploadManager.releaseLock(uploadID); 113 | } 114 | }, res -> { 115 | if (res.succeeded()) { 116 | if (res.result() == -1) { 117 | response.setStatusCode(404); 118 | } else { 119 | response.putHeader("Upload-Offset", String.valueOf(res.result())); 120 | response.setStatusCode(204); 121 | } 122 | } else { 123 | response.setStatusCode(500); 124 | } 125 | response.putHeader("Tus-Resumable", tusResumable); 126 | response.end(); 127 | }); 128 | } 129 | } 130 | } 131 | } 132 | 133 | private boolean checkContentLengthWithCurrentOffset(Optional contentLength, Long offset, Long entityLength) { 134 | return contentLength.map(aLong -> (aLong + offset <= entityLength)).orElse(true); 135 | } 136 | } -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/PostHandler.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.Parameter; 5 | import io.swagger.v3.oas.annotations.enums.ParameterIn; 6 | import io.swagger.v3.oas.annotations.headers.Header; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import io.vertx.core.http.HttpServerResponse; 10 | import io.vertx.ext.web.RoutingContext; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.beans.factory.annotation.Value; 14 | import org.springframework.stereotype.Component; 15 | 16 | import javax.inject.Inject; 17 | import java.util.Optional; 18 | 19 | import static com.tus.oss.server.core.Utils.*; 20 | 21 | /** 22 | * @author ckaratza 23 | * The creation and concatenation extension implementation. 24 | */ 25 | @Component 26 | public class PostHandler { 27 | 28 | private static final Logger log = LoggerFactory.getLogger(PostHandler.class); 29 | 30 | private final String tusResumable; 31 | private final UploadManager uploadManager; 32 | 33 | @Inject 34 | public PostHandler(@Value("${TusResumable}") String tusResumable, UploadManager uploadManager) { 35 | this.tusResumable = tusResumable; 36 | this.uploadManager = uploadManager; 37 | } 38 | 39 | 40 | @Operation(summary = "Creates a new upload unit-of-work.", method = "POST", 41 | parameters = {@Parameter(name = "Upload-Length", in = ParameterIn.HEADER, required = true), 42 | @Parameter(name = "Upload-Concat", in = ParameterIn.HEADER, schema = @Schema(type = "string")), 43 | @Parameter(name = "Upload-Metadata", in = ParameterIn.HEADER, schema = @Schema(type = "string"))}, 44 | responses = { 45 | @ApiResponse(responseCode = "413", description = "Upload size too large."), 46 | @ApiResponse(responseCode = "400", description = "Bad Request."), 47 | @ApiResponse(responseCode = "201", description = "Upload unit of work Created.", 48 | headers = {@Header(name = "Location", description = "The uri of the created upload unit of work.", required = true)})}) 49 | void handleRequest(RoutingContext ctx) { 50 | HttpServerResponse response = ctx.response(); 51 | Optional lengthHeader = getHeaderAsLong("Upload-Length", ctx); 52 | Optional uploadConcatHeader = getHeaderAsString("Upload-Concat", ctx); 53 | Optional uploadMetadataHeader = getHeaderAsString("Upload-Metadata", ctx); 54 | boolean isPartial = "partial".equals(uploadConcatHeader.orElse("")); 55 | boolean isPotentiallyFinal = uploadConcatHeader.orElse("").startsWith("final;"); 56 | if (isPotentiallyFinal) { 57 | log.info("Final Upload-Concat {}.", uploadConcatHeader.get()); 58 | String[] parts = uploadConcatHeader.get().substring("final;".length() + 1).split(" "); 59 | if (parts.length <= 1) { 60 | response.setStatusCode(400); 61 | } else { 62 | Optional location = uploadManager.mergePartialUploads(extractPartialUploadIds(parts), uploadMetadataHeader); 63 | if (location.isPresent()) { 64 | response.putHeader("Location", location.get()); 65 | response.setStatusCode(201); 66 | } else { 67 | response.setStatusCode(500); 68 | } 69 | } 70 | } else if (lengthHeader.isPresent()) { 71 | if (uploadManager.checkServerSizeConstraint(lengthHeader.get())) { 72 | Optional location = uploadManager.createUpload(lengthHeader.get(), 73 | uploadMetadataHeader, isPartial); 74 | if (location.isPresent()) { 75 | response.putHeader("Location", location.get()); 76 | response.setStatusCode(201); 77 | } else { 78 | response.setStatusCode(500); 79 | } 80 | } else { 81 | response.setStatusCode(413); 82 | } 83 | } else { 84 | response.setStatusCode(400); 85 | } 86 | response.putHeader("Tus-Resumable", tusResumable); 87 | response.end(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/ServerVerticle.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import com.tus.oss.server.openapi.OpenApiRoutePublisher; 4 | import io.vertx.core.AbstractVerticle; 5 | import io.vertx.ext.web.Router; 6 | import io.vertx.ext.web.handler.BodyHandler; 7 | import io.vertx.ext.web.handler.LoggerHandler; 8 | import io.vertx.ext.web.handler.ResponseTimeHandler; 9 | import org.springframework.beans.factory.annotation.Value; 10 | import org.springframework.stereotype.Component; 11 | 12 | import javax.inject.Inject; 13 | 14 | /** 15 | * @author ckaratza 16 | * The Tus Server Verticle with the route definitions. 17 | */ 18 | @Component 19 | public class ServerVerticle extends AbstractVerticle { 20 | 21 | private final Integer port; 22 | private final String host; 23 | private final String contextPath; 24 | private final OptionsHandler optionsHandler; 25 | private final HeadHandler headHandler; 26 | private final PostHandler postHandler; 27 | private final PatchHandler patchHandler; 28 | private final DeleteHandler deleteHandler; 29 | 30 | @Inject 31 | public ServerVerticle(@Value("${port}") Integer port, @Value("${host}") String host, @Value("${contextPath}") String contextPath, 32 | OptionsHandler optionsHandler, HeadHandler headHandler, PostHandler postHandler, 33 | PatchHandler patchHandler, DeleteHandler deleteHandler) { 34 | this.port = port; 35 | this.host = host; 36 | this.contextPath = contextPath; 37 | this.optionsHandler = optionsHandler; 38 | this.headHandler = headHandler; 39 | this.postHandler = postHandler; 40 | this.patchHandler = patchHandler; 41 | this.deleteHandler = deleteHandler; 42 | } 43 | 44 | @Override 45 | public void start() { 46 | Router router = Router.router(vertx); 47 | router.route().handler(BodyHandler.create()).handler(LoggerHandler.create()).handler(ResponseTimeHandler.create()).enable(); 48 | router.head(contextPath + ":uploadID").handler(headHandler::handleRequest); 49 | router.options(contextPath).handler(optionsHandler::handleRequest); 50 | router.post(contextPath).handler(postHandler::handleRequest); 51 | router.delete(contextPath + ":uploadID").handler(deleteHandler::handleRequest); 52 | router.patch(contextPath + ":uploadID").handler(patchHandler::handleRequestForPatch); 53 | //POST can replace PATCH because of buggy jre... 54 | router.post(contextPath + ":uploadID").handler(patchHandler::handleRequestForPost); 55 | OpenApiRoutePublisher.publishOpenApiSpec(router, contextPath + "spec", 56 | "Tus.io Resumable File Upload Protocol Server", "1.0.0", "http://" + host + ":" + port + "/"); 57 | vertx.createHttpServer().requestHandler(router::accept).listen(port, host); 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/UploadInfo.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | /** 4 | * @author ckaratza 5 | */ 6 | public class UploadInfo { 7 | private long entityLength; 8 | private long offset; 9 | private String metadata; 10 | private String creationUrl; 11 | private boolean isPartial; 12 | private String uploadConcatMergedValue; 13 | 14 | public long getEntityLength() { 15 | return entityLength; 16 | } 17 | 18 | public void setEntityLength(long entityLength) { 19 | this.entityLength = entityLength; 20 | } 21 | 22 | public long getOffset() { 23 | return offset; 24 | } 25 | 26 | public void setOffset(long offset) { 27 | this.offset = offset; 28 | } 29 | 30 | public String getMetadata() { 31 | return metadata; 32 | } 33 | 34 | public void setMetadata(String metadata) { 35 | this.metadata = metadata; 36 | } 37 | 38 | public String getCreationUrl() { 39 | return creationUrl; 40 | } 41 | 42 | public void setCreationUrl(String creationUrl) { 43 | this.creationUrl = creationUrl; 44 | } 45 | 46 | public boolean isPartial() { 47 | return isPartial; 48 | } 49 | 50 | public void setPartial(boolean partial) { 51 | isPartial = partial; 52 | } 53 | 54 | public String getUploadConcatMergedValue() { 55 | return uploadConcatMergedValue; 56 | } 57 | 58 | public void setUploadConcatMergedValue(String uploadConcatMergedValue) { 59 | this.uploadConcatMergedValue = uploadConcatMergedValue; 60 | } 61 | 62 | @Override 63 | public String toString() { 64 | return "UploadInfo{" + 65 | "entityLength=" + entityLength + 66 | ", offset=" + offset + 67 | ", metadata='" + metadata + '\'' + 68 | ", creationUrl='" + creationUrl + '\'' + 69 | ", isPartial=" + isPartial + 70 | '}'; 71 | } 72 | 73 | public static class ChecksumInfo { 74 | private final String algorithm; 75 | private final String value; 76 | 77 | ChecksumInfo(String algorithm, String value) { 78 | this.algorithm = algorithm; 79 | this.value = value; 80 | } 81 | 82 | public String getAlgorithm() { 83 | return algorithm; 84 | } 85 | 86 | public String getValue() { 87 | return value; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/UploadManager.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import io.vertx.core.http.HttpServerRequest; 4 | 5 | import java.util.Optional; 6 | 7 | /** 8 | * @author ckaratza 9 | * UploadManager interface decouples protocol from storage implementation by managing the upload information and flow. 10 | * The {@code delegateToStoragePlugin} method delegates to the available storage plugin(s) to perform the actual persistance. 11 | */ 12 | public interface UploadManager { 13 | 14 | Optional findUploadInfo(final String id); 15 | 16 | Optional createUpload(Long totalLength, Optional uploadMetadata, boolean isPartial); 17 | 18 | Optional mergePartialUploads(String[] ids, Optional uploadMetadata); 19 | 20 | boolean checkServerSizeConstraint(final Long totalLength); 21 | 22 | boolean discardUpload(final String id); 23 | 24 | boolean acquireLock(final String id); 25 | 26 | void releaseLock(final String id); 27 | 28 | long delegateToStoragePlugin(HttpServerRequest request, final String id, long offset, Optional checksum); 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/core/Utils.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.core; 2 | 3 | import io.vertx.ext.web.RoutingContext; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.util.*; 8 | 9 | /** 10 | * @author ckaratza 11 | * Request Handling utility functions. 12 | */ 13 | public final class Utils { 14 | 15 | private static final Logger log = LoggerFactory.getLogger(Utils.class); 16 | 17 | public static Map parseMetadata(String metadata) { 18 | HashMap map = new HashMap<>(); 19 | if (metadata == null) { 20 | return map; 21 | } 22 | String[] pairs = metadata.split(","); 23 | for (String pair : pairs) { 24 | String[] element = pair.trim().split(" "); 25 | if (element.length != 2) { 26 | log.warn("Ignoring metadata element: {}.", pair); 27 | continue; 28 | } 29 | String key = element[0]; 30 | byte[] value; 31 | try { 32 | value = Base64.getUrlDecoder().decode(element[1]); 33 | } catch (IllegalArgumentException iae) { 34 | log.warn("Invalid encoding of metadata element: {}.", pair); 35 | continue; 36 | } 37 | map.put(key, new String(value)); 38 | } 39 | return map; 40 | } 41 | 42 | static Optional getHeaderAsLong(final String key, final RoutingContext ctx) { 43 | String value = ctx.request().getHeader(key); 44 | try { 45 | return Optional.of(Long.valueOf(value)); 46 | } catch (NumberFormatException nfe) { 47 | return Optional.empty(); 48 | } 49 | } 50 | 51 | static Optional getHeaderAsString(final String key, final RoutingContext ctx) { 52 | String value = ctx.request().getHeader(key); 53 | return Optional.ofNullable(value); 54 | } 55 | 56 | static Optional getHeaderAsChecksumInfo(final String key, RoutingContext ctx) { 57 | String value = ctx.request().getHeader(key); 58 | if (value == null) return Optional.empty(); 59 | String[] pair = value.split(" "); 60 | if (pair.length == 2) { 61 | return Optional.of(new UploadInfo.ChecksumInfo(pair[0], pair[1])); 62 | } 63 | return Optional.empty(); 64 | } 65 | 66 | static String[] extractPartialUploadIds(String[] fullParts) { 67 | return Arrays.stream(fullParts).map(Utils::getLastBitFromUrl).toArray(String[]::new); 68 | } 69 | 70 | private static String getLastBitFromUrl(final String url) { 71 | return url.replaceFirst(".*/([^/?]+).*", "$1"); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/impl/RedisConfig.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.impl; 2 | 3 | import com.lambdaworks.redis.RedisClient; 4 | import com.lambdaworks.redis.RedisURI; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | /** 10 | * @author ckaratza 11 | * The redis client configuration provider. 12 | */ 13 | @Configuration 14 | public class RedisConfig { 15 | 16 | @Bean 17 | RedisClient redisClient(@Value("${redis.host}") String host, @Value("${redis.port}") Integer port) { 18 | return RedisClient.create(RedisURI.create(host, port)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/impl/RedisUploadManager.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.impl; 2 | 3 | import com.google.gson.Gson; 4 | import com.lambdaworks.redis.RedisClient; 5 | import com.lambdaworks.redis.api.StatefulRedisConnection; 6 | import com.tus.oss.server.core.UploadInfo; 7 | import com.tus.oss.server.core.UploadManager; 8 | import io.vertx.core.http.HttpServerRequest; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | import java.util.Optional; 15 | import java.util.UUID; 16 | 17 | import static com.tus.oss.server.core.Utils.parseMetadata; 18 | 19 | /** 20 | * @author ckaratza 21 | * A redis backed-up implementation of the {@link UploadManager} interface. It uses redis getset to acquire an upload lock during the patch operation. 22 | * It delegates to an appropriate storage plugin to finish the persistence operation. Implementors could change the delegation with a more sophisticated 23 | * delegation decision if multiple storage plugins are involved based on metadata information. 24 | */ 25 | public class RedisUploadManager implements UploadManager { 26 | 27 | private static final Logger log = LoggerFactory.getLogger(RedisUploadManager.class); 28 | private static final String LOCK_PREFIX = "LPRUM_"; 29 | private final StatefulRedisConnection connection; 30 | private final String basePath; 31 | private final String contextPath; 32 | private final Long tusMaxSize; 33 | private final Gson gson; 34 | private final StoragePlugin storagePlugin; 35 | 36 | public RedisUploadManager(RedisClient redisClient, Long tusMaxSize, String basePath, String contextPath, StoragePlugin storagePlugin) { 37 | this.tusMaxSize = tusMaxSize; 38 | this.basePath = basePath; 39 | this.contextPath = contextPath; 40 | connection = redisClient.connect(); 41 | this.storagePlugin = storagePlugin; 42 | gson = new Gson(); 43 | } 44 | 45 | private Optional get(String id) { 46 | String value = connection.sync().get(id); 47 | if (value == null) return Optional.empty(); 48 | return Optional.of(gson.fromJson(value, UploadInfo.class)); 49 | } 50 | 51 | private void set(String id, UploadInfo info) { 52 | connection.sync().set(id, gson.toJson(info)); 53 | } 54 | 55 | private boolean del(String id) { 56 | Long keyDeleted = connection.sync().del(id); 57 | return keyDeleted == 1; 58 | } 59 | 60 | @Override 61 | public Optional findUploadInfo(String id) { 62 | return get(id); 63 | } 64 | 65 | @Override 66 | public Optional createUpload(Long totalLength, Optional uploadMetadata, boolean isPartial) { 67 | String id = UUID.randomUUID().toString(); 68 | UploadInfo info = new UploadInfo(); 69 | info.setCreationUrl(basePath + contextPath + id); 70 | info.setEntityLength(totalLength); 71 | info.setOffset(0); 72 | info.setPartial(isPartial); 73 | if (uploadMetadata.isPresent()) { 74 | if (parseMetadata(uploadMetadata.get()).size() > 0) { 75 | info.setMetadata(uploadMetadata.get()); 76 | } 77 | } 78 | set(id, info); 79 | log.info("New Upload created {}.", info); 80 | return Optional.of(info.getCreationUrl()); 81 | } 82 | 83 | @Override 84 | public Optional mergePartialUploads(String[] ids, Optional uploadMetadata) { 85 | List partials = new ArrayList<>(); 86 | for (String id : ids) { 87 | Optional info = get(id); 88 | if (!info.isPresent()) { 89 | log.warn("Partial Upload not found {}.", id); 90 | return Optional.empty(); 91 | } 92 | if (!info.get().isPartial()) { 93 | log.warn("Upload not partial {}.", id); 94 | return Optional.empty(); 95 | } 96 | if (info.get().getOffset() != info.get().getEntityLength()) { 97 | log.warn("Partial {} not completed.", id); 98 | return Optional.empty(); 99 | } 100 | partials.add(info.get()); 101 | } 102 | String id = UUID.randomUUID().toString(); 103 | UploadInfo info = new UploadInfo(); 104 | info.setCreationUrl(basePath + contextPath + id); 105 | info.setEntityLength(partials.stream().map(UploadInfo::getEntityLength).reduce(0L, (x, y) -> x + y)); 106 | info.setOffset(info.getEntityLength()); 107 | info.setPartial(false); 108 | if (uploadMetadata.isPresent()) { 109 | if (parseMetadata(uploadMetadata.get()).size() > 0) { 110 | info.setMetadata(uploadMetadata.get()); 111 | } 112 | } 113 | set(id, info); 114 | log.info("New Upload created {} from partial uploads {}.", info, ids); 115 | return Optional.of(info.getCreationUrl()); 116 | } 117 | 118 | @Override 119 | public boolean checkServerSizeConstraint(Long totalLength) { 120 | return totalLength <= tusMaxSize; 121 | } 122 | 123 | @Override 124 | public boolean discardUpload(String id) { 125 | boolean deleted = del(id); 126 | log.info("Deleted {}.", deleted); 127 | return deleted; 128 | } 129 | 130 | @Override 131 | public boolean acquireLock(String id) { 132 | String oldValue = connection.sync().getset(LOCK_PREFIX + id, "1"); 133 | return !"1".equals(oldValue); 134 | } 135 | 136 | @Override 137 | public void releaseLock(String id) { 138 | del(LOCK_PREFIX + id); 139 | } 140 | 141 | @Override 142 | public long delegateToStoragePlugin(HttpServerRequest request, String id, long offset, Optional checksum) { 143 | Optional uploadInfo = get(id); 144 | if (uploadInfo.isPresent()) { 145 | long bytesStored = storagePlugin.delegateBytesToStorage(request, uploadInfo.get(), offset, checksum); 146 | long totalSoFar = uploadInfo.get().getOffset() + bytesStored; 147 | uploadInfo.get().setOffset(totalSoFar); 148 | set(id, uploadInfo.get()); 149 | log.info("Persisted Upload with id {} to store from offset {} until {} for entity length {}.", id, offset, 150 | uploadInfo.get().getOffset(), uploadInfo.get().getEntityLength()); 151 | return totalSoFar; 152 | } else 153 | return -1; 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/impl/StoragePlugin.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.impl; 2 | 3 | import com.tus.oss.server.core.UploadInfo; 4 | import io.vertx.core.http.HttpServerRequest; 5 | import org.springframework.stereotype.Component; 6 | 7 | import java.util.Optional; 8 | 9 | /** 10 | * @author ckaratza 11 | * Feed the storage plugin with all the upload information in order to decide where and how it will store the bytes. 12 | * Depending on the storage provider implementors can adjust the implementation. 13 | * For now just simulate that bytes were actually processed... 14 | */ 15 | @Component 16 | class StoragePlugin { 17 | 18 | long delegateBytesToStorage(HttpServerRequest request, UploadInfo info, long fromOffset, Optional checksum) { 19 | //Replace it with a storage implementation of your own. 20 | //Simulate IO operation... 21 | try { 22 | Thread.sleep(1000); 23 | } catch (InterruptedException e) { 24 | e.printStackTrace(); 25 | } 26 | //Stored until the end... 27 | return info.getEntityLength() - fromOffset; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/openapi/AnnotationMappers.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.openapi; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.models.headers.Header; 5 | import io.swagger.v3.oas.models.media.Schema; 6 | import io.swagger.v3.oas.models.parameters.Parameter; 7 | import io.swagger.v3.oas.models.responses.ApiResponse; 8 | import io.swagger.v3.oas.models.responses.ApiResponses; 9 | import org.apache.commons.lang3.tuple.ImmutablePair; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | 13 | import java.util.Arrays; 14 | import java.util.List; 15 | import java.util.stream.Collectors; 16 | 17 | /** 18 | * @author ckaratza 19 | * Simple OpenApi Annotation mapping to OpenApi models. 20 | */ 21 | final class AnnotationMappers { 22 | 23 | private static final Logger log = LoggerFactory.getLogger(AnnotationMappers.class); 24 | 25 | static void decorateOperationFromAnnotation(Operation annotation, io.swagger.v3.oas.models.Operation operation) { 26 | operation.summary(annotation.summary()); 27 | operation.description(annotation.description()); 28 | operation.operationId(annotation.operationId()); 29 | operation.deprecated(annotation.deprecated()); 30 | ApiResponses apiResponses = new ApiResponses(); 31 | apiResponses.putAll( 32 | Arrays.stream(annotation.responses()).map(response -> { 33 | ApiResponse apiResponse = new ApiResponse(); 34 | apiResponse.description(response.description()); 35 | Arrays.stream(response.headers()).forEach(header -> { 36 | Header h = new Header(); 37 | h.description(header.description()); 38 | h.deprecated(header.deprecated()); 39 | h.allowEmptyValue(header.allowEmptyValue()); 40 | h.required(header.required()); 41 | apiResponse.addHeaderObject(header.name(), h); 42 | }); 43 | return new ImmutablePair<>(response.responseCode(), apiResponse); 44 | }).collect(Collectors.toMap(x -> x.left, x -> x.right))); 45 | operation.responses(apiResponses); 46 | Arrays.stream(annotation.parameters()).forEach(parameter -> { 47 | Parameter p = findAlreadyProcessedParamFromVertxRoute(parameter.name(), operation.getParameters()); 48 | if (p == null) { 49 | p = new Parameter(); 50 | operation.addParametersItem(p); 51 | } 52 | p.name(parameter.name()); 53 | p.description(parameter.description()); 54 | p.allowEmptyValue(parameter.allowEmptyValue()); 55 | try { 56 | p.style(Parameter.StyleEnum.valueOf(parameter.style().name())); 57 | } catch (IllegalArgumentException ie) { 58 | log.warn(ie.getMessage()); 59 | } 60 | p.setRequired(parameter.required()); 61 | p.in(parameter.in().name().toLowerCase()); 62 | 63 | Schema schema = new Schema(); 64 | io.swagger.v3.oas.annotations.media.Schema s = parameter.schema(); 65 | if (!s.ref().isEmpty()) schema.set$ref(s.ref()); 66 | schema.setDeprecated(s.deprecated()); 67 | schema.setDescription(s.description()); 68 | schema.setName(s.name()); 69 | schema.setType(s.type()); 70 | schema.setFormat(s.format()); 71 | p.schema(schema); 72 | }); 73 | } 74 | 75 | private static Parameter findAlreadyProcessedParamFromVertxRoute(final String name, List parameters) { 76 | for (Parameter parameter : parameters) { 77 | if (name.equals(parameter.getName())) 78 | return parameter; 79 | } 80 | return null; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/openapi/OpenApiRoutePublisher.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.openapi; 2 | 3 | import io.swagger.v3.core.util.Json; 4 | import io.swagger.v3.core.util.Yaml; 5 | import io.swagger.v3.oas.models.OpenAPI; 6 | import io.vertx.ext.web.Router; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | import java.util.Optional; 11 | 12 | /** 13 | * @author ckaratza 14 | * Exposes the OpenAPI spec as a vertx route. 15 | */ 16 | public final class OpenApiRoutePublisher { 17 | 18 | private final static Map generatedSpecs = new HashMap<>(); 19 | 20 | public synchronized static void publishOpenApiSpec(Router router, String path, String title, String version, String serverUrl) { 21 | Optional spec = Optional.ofNullable(generatedSpecs.get(path)).or(() -> { 22 | OpenAPI openAPI = OpenApiSpecGenerator.generateOpenApiSpecFromRouter(router, title, version, serverUrl); 23 | generatedSpecs.put(path, openAPI); 24 | return Optional.of(openAPI); 25 | }); 26 | if (spec.isPresent()) { 27 | router.get(path + ".json").handler(routingContext -> 28 | routingContext.response() 29 | .putHeader("Content-Type", "application/json") 30 | .end(Json.pretty(spec.get()))); 31 | router.get(path + ".yaml").handler(routingContext -> 32 | routingContext.response() 33 | .putHeader("Content-Type", "text/plain") 34 | .end(Yaml.pretty(spec.get()))); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/tus/oss/server/openapi/OpenApiSpecGenerator.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.openapi; 2 | 3 | import io.swagger.v3.oas.models.Components; 4 | import io.swagger.v3.oas.models.OpenAPI; 5 | import io.swagger.v3.oas.models.Operation; 6 | import io.swagger.v3.oas.models.PathItem; 7 | import io.swagger.v3.oas.models.info.Info; 8 | import io.swagger.v3.oas.models.parameters.Parameter; 9 | import io.swagger.v3.oas.models.servers.Server; 10 | import io.vertx.core.Handler; 11 | import io.vertx.core.http.HttpMethod; 12 | import io.vertx.ext.web.Route; 13 | import io.vertx.ext.web.Router; 14 | import io.vertx.ext.web.RoutingContext; 15 | import org.slf4j.Logger; 16 | import org.slf4j.LoggerFactory; 17 | 18 | import java.lang.reflect.Field; 19 | import java.util.*; 20 | import java.util.stream.Collectors; 21 | 22 | import static com.tus.oss.server.openapi.AnnotationMappers.decorateOperationFromAnnotation; 23 | 24 | /** 25 | * @author ckaratza 26 | * Tries to interrogate vertx router and build an OpenAPI specification. Tries to interrogate handlers of route with 27 | * OpenApi Operation methods and cross-reference with route information. 28 | */ 29 | final class OpenApiSpecGenerator { 30 | private static final Logger log = LoggerFactory.getLogger(OpenApiSpecGenerator.class); 31 | 32 | static OpenAPI generateOpenApiSpecFromRouter(Router router, String title, String version, String serverUrl) { 33 | log.info("Generating Spec for vertx routes."); 34 | OpenAPI openAPI = new OpenAPI(); 35 | Info info = new Info(); 36 | info.setTitle(title); 37 | info.setVersion(version); 38 | Server server = new Server(); 39 | server.setUrl(serverUrl); 40 | openAPI.servers(Collections.singletonList(server)); 41 | openAPI.setInfo(info); 42 | 43 | Map paths = extractAllPaths(router); 44 | extractOperationInfo(router, paths); 45 | paths.forEach(openAPI::path); 46 | return openAPI; 47 | } 48 | 49 | static private Map extractAllPaths(Router router) { 50 | return router.getRoutes().stream().filter(x -> x.getPath() != null) 51 | .map(Route::getPath).distinct().collect(Collectors.toMap(x -> x, x -> new PathItem())); 52 | } 53 | 54 | static private void extractOperationInfo(Router router, Map paths) { 55 | router.getRoutes().forEach(route -> { 56 | PathItem pathItem = paths.get(route.getPath()); 57 | if (pathItem != null) { 58 | List operations = extractOperations(route, pathItem); 59 | operations.forEach(operation -> operation.setParameters(extractPathParams(route.getPath()))); 60 | } 61 | }); 62 | decorateOperationsFromAnnotationsOnHandlers(router, paths); 63 | } 64 | 65 | private static void decorateOperationsFromAnnotationsOnHandlers(Router router, Map paths) { 66 | router.getRoutes().stream().filter(x -> x.getPath() != null).forEach(route -> { 67 | try { 68 | Field contextHandlers = route.getClass().getDeclaredField("contextHandlers"); 69 | contextHandlers.setAccessible(true); 70 | List> handlers = (List>) contextHandlers.get(route); 71 | handlers.forEach(handler -> { 72 | try { 73 | Class delegate = handler.getClass().getDeclaredField("arg$1").getType(); 74 | Arrays.stream(delegate.getDeclaredMethods()).distinct().forEach(method -> { 75 | io.swagger.v3.oas.annotations.Operation annotation = method.getAnnotation(io.swagger.v3.oas.annotations.Operation.class); 76 | if (annotation != null) { 77 | String httpMethod = annotation.method(); 78 | PathItem pathItem = paths.get(route.getPath()); 79 | Operation matchedOperation = null; 80 | switch (PathItem.HttpMethod.valueOf(httpMethod.toUpperCase())) { 81 | case TRACE: 82 | matchedOperation = pathItem.getTrace(); 83 | break; 84 | case PUT: 85 | matchedOperation = pathItem.getPut(); 86 | break; 87 | case POST: 88 | matchedOperation = pathItem.getPost(); 89 | break; 90 | case PATCH: 91 | matchedOperation = pathItem.getPatch(); 92 | break; 93 | case GET: 94 | matchedOperation = pathItem.getGet(); 95 | break; 96 | case OPTIONS: 97 | matchedOperation = pathItem.getOptions(); 98 | break; 99 | case HEAD: 100 | matchedOperation = pathItem.getHead(); 101 | break; 102 | case DELETE: 103 | matchedOperation = pathItem.getDelete(); 104 | break; 105 | default: 106 | break; 107 | } 108 | if (matchedOperation != null) 109 | decorateOperationFromAnnotation(annotation, matchedOperation); 110 | } 111 | }); 112 | } catch (NoSuchFieldException e) { 113 | log.warn(e.getMessage()); 114 | } 115 | }); 116 | } catch (IllegalAccessException | NoSuchFieldException e) { 117 | log.warn(e.getMessage()); 118 | } 119 | }); 120 | } 121 | 122 | private static List extractPathParams(String fullPath) { 123 | String[] split = fullPath.split("\\/"); 124 | return Arrays.stream(split).filter(x -> x.startsWith(":")).map(x -> { 125 | Parameter param = new Parameter(); 126 | param.name(x.substring(1)); 127 | return param; 128 | }).collect(Collectors.toList()); 129 | } 130 | 131 | private static List extractOperations(Route route, PathItem pathItem) { 132 | try { 133 | Field methods = route.getClass().getDeclaredField("methods"); 134 | methods.setAccessible(true); 135 | Set httpMethods = (Set) methods.get(route); 136 | return httpMethods.stream().map(httpMethod -> { 137 | Operation operation = new Operation(); 138 | switch (PathItem.HttpMethod.valueOf(httpMethod.name())) { 139 | case TRACE: 140 | pathItem.trace(operation); 141 | break; 142 | case PUT: 143 | pathItem.put(operation); 144 | break; 145 | case POST: 146 | pathItem.post(operation); 147 | break; 148 | case PATCH: 149 | pathItem.patch(operation); 150 | break; 151 | case GET: 152 | pathItem.get(operation); 153 | break; 154 | case OPTIONS: 155 | pathItem.options(operation); 156 | break; 157 | case HEAD: 158 | pathItem.head(operation); 159 | break; 160 | case DELETE: 161 | pathItem.delete(operation); 162 | break; 163 | default: 164 | break; 165 | } 166 | return operation; 167 | }).collect(Collectors.toList()); 168 | 169 | } catch (NoSuchFieldException | IllegalAccessException e) { 170 | log.warn(e.getMessage()); 171 | return Collections.emptyList(); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _______ __ __ _______ _______ _______ ______ __ __ _______ ______ 2 | | || | | || | | || || _ | | | | || || _ | 3 | |_ _|| | | || _____| ____ | _____|| ___|| | || | |_| || ___|| | || 4 | | | | |_| || |_____ |____| | |_____ | |___ | |_||_ | || |___ | |_||_ 5 | | | | ||_____ | |_____ || ___|| __ || || ___|| __ | 6 | | | | | _____| | _____| || |___ | | | | | | | |___ | | | | 7 | |___| |_______||_______| |_______||_______||___| |_| |___| |_______||___| |_| -------------------------------------------------------------------------------- /src/test/java/com/tus/oss/server/test/SimpleUploadTest.java: -------------------------------------------------------------------------------- 1 | package com.tus.oss.server.test; 2 | 3 | import io.tus.java.client.*; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.io.File; 8 | import java.io.IOException; 9 | import java.net.URL; 10 | 11 | /** 12 | * @author ckaratza 13 | * A simple upload example using a tus-java-client [https://github.com/tus/tus-java-client]. 14 | */ 15 | public class SimpleUploadTest { 16 | 17 | private static final Logger log = LoggerFactory.getLogger(SimpleUploadTest.class); 18 | 19 | public static void main(String[] args) throws IOException, ProtocolException { 20 | if (args.length != 2) { 21 | log.info("Usage: Supply 2 arguments-> 1. [TUS_SERVER_URL] 2. [FILE_TO_UPLOAD_PATH]"); 22 | return; 23 | } 24 | 25 | TusClient client = new TusClient(); 26 | client.setUploadCreationURL(new URL(args[0])); 27 | client.enableResuming(new TusURLMemoryStore()); 28 | 29 | File file = new File(args[1]); 30 | final TusUpload upload = new TusUpload(file); 31 | log.info("Starting upload {}...", upload); 32 | TusExecutor executor = new TusExecutor() { 33 | @Override 34 | protected void makeAttempt() throws ProtocolException, IOException { 35 | TusUploader uploader = client.resumeOrCreateUpload(upload); 36 | uploader.setChunkSize(1024); 37 | do { 38 | long totalBytes = upload.getSize(); 39 | long bytesUploaded = uploader.getOffset(); 40 | double progress = (double) bytesUploaded / totalBytes * 100; 41 | log.info("Upload at {}%.\n", progress); 42 | } while (uploader.uploadChunk() > -1); 43 | uploader.finish(); 44 | log.info("Upload finished."); 45 | log.info("Upload available at: {}.", uploader.getUploadURL().toString()); 46 | } 47 | }; 48 | executor.makeAttempts(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/test/partialsUpload.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | TUS_URL=http://localhost:6969/uploads/ 4 | 5 | SIZE1=27 6 | FILE1="./toUploadChunk1.txt" 7 | SIZE2=24 8 | FILE2="./toUploadChunk2.txt" 9 | SIZE3=30 10 | FILE3="./toUploadChunk3.txt" 11 | 12 | OUTPUT1=$(curl -X POST -i $TUS_URL -H 'Tus-Resumable: 1.0.0' -H "Upload-Concat: partial" -H "Upload-Length: $SIZE1" ) 13 | echo "$OUTPUT1" 14 | 15 | URL1=$(echo "$OUTPUT1" | \ 16 | grep 'Location:' | \ 17 | sed "s/$(printf '\r')\$//" | \ 18 | awk '{print $2}') 19 | 20 | echo "URL1 is $URL1" 21 | 22 | curl -X PATCH -i $URL1 \ 23 | -H 'Tus-Resumable: 1.0.0' \ 24 | -H 'Upload-Offset: 0' \ 25 | -H 'Content-Type: application/offset+octet-stream' \ 26 | --upload-file $FILE1 27 | 28 | OUTPUT2=$(curl -X POST -i $TUS_URL -H 'Tus-Resumable: 1.0.0' -H "Upload-Concat: partial" -H "Upload-Length: $SIZE2" ) 29 | echo "$OUTPUT2" 30 | 31 | URL2=$(echo "$OUTPUT2" | \ 32 | grep 'Location:' | \ 33 | sed "s/$(printf '\r')\$//" | \ 34 | awk '{print $2}') 35 | 36 | echo "URL2 is $URL2" 37 | 38 | curl -X PATCH -i $URL2 \ 39 | -H 'Tus-Resumable: 1.0.0' \ 40 | -H 'Upload-Offset: 0' \ 41 | -H 'Content-Type: application/offset+octet-stream' \ 42 | --upload-file $FILE2 43 | 44 | OUTPUT3=$(curl -X POST -i $TUS_URL -H 'Tus-Resumable: 1.0.0' -H "Upload-Concat: partial" -H "Upload-Length: $SIZE3" ) 45 | echo "$OUTPUT3" 46 | 47 | URL3=$(echo "$OUTPUT3" | \ 48 | grep 'Location:' | \ 49 | sed "s/$(printf '\r')\$//" | \ 50 | awk '{print $2}') 51 | 52 | echo "URL3 is $URL3" 53 | 54 | curl -X PATCH -i $URL3 \ 55 | -H 'Tus-Resumable: 1.0.0' \ 56 | -H 'Upload-Offset: 0' \ 57 | -H 'Content-Type: application/offset+octet-stream' \ 58 | --upload-file $FILE3 59 | 60 | FINAL="final;$URL1 $URL2 $URL3" 61 | 62 | echo "FINAL is $FINAL" 63 | 64 | OUTPUT4=$(curl -X POST -i $TUS_URL -H 'Tus-Resumable: 1.0.0' -H "Upload-Concat: $FINAL" ) 65 | echo "$OUTPUT4" 66 | 67 | URL4=$(echo "$OUTPUT4" | \ 68 | grep 'Location:' | \ 69 | sed "s/$(printf '\r')\$//" | \ 70 | awk '{print $2}') 71 | 72 | curl -X HEAD -i $URL4 -------------------------------------------------------------------------------- /src/test/simpleUpload.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SIZE=83 4 | FILE="./toUpload.txt" 5 | TUS_URL=http://localhost:6969/uploads/ 6 | 7 | OUTPUT=$(curl -X POST -i $TUS_URL -H 'Tus-Resumable: 1.0.0' -H "Upload-Length: $SIZE" ) 8 | echo "$OUTPUT" 9 | 10 | URL=$(echo "$OUTPUT" | \ 11 | grep 'Location:' | \ 12 | sed "s/$(printf '\r')\$//" | \ 13 | awk '{print $2}') 14 | 15 | echo "URL is $URL" 16 | 17 | curl -X PATCH -i $URL \ 18 | -H 'Tus-Resumable: 1.0.0' \ 19 | -H 'Upload-Offset: 0' \ 20 | -H 'Content-Type: application/offset+octet-stream' \ 21 | --upload-file $FILE -------------------------------------------------------------------------------- /src/test/toUpload.txt: -------------------------------------------------------------------------------- 1 | Please upload me in chunks. 2 | Please use Tus protocol! 3 | Thank you and have a nice day. -------------------------------------------------------------------------------- /src/test/toUploadChunk1.txt: -------------------------------------------------------------------------------- 1 | Please upload me in chunks. -------------------------------------------------------------------------------- /src/test/toUploadChunk2.txt: -------------------------------------------------------------------------------- 1 | Please use Tus protocol! -------------------------------------------------------------------------------- /src/test/toUploadChunk3.txt: -------------------------------------------------------------------------------- 1 | Thank you and have a nice day. -------------------------------------------------------------------------------- /tus-server.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | redis: 4 | image: redis 5 | container_name: redis 6 | ports: 7 | - "6379:6379" 8 | broker: 9 | image: tus_server 10 | container_name: tus_server 11 | environment: 12 | REDIS_IP: redis 13 | ports: 14 | - '6969:6969' 15 | links: 16 | - redis 17 | --------------------------------------------------------------------------------