diff --git a/.gitignore b/.gitignore index 9b583bd81..542ddf294 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ nbdist/ .nb-gradle/ classes -.java-version \ No newline at end of file +.java-version +secrets diff --git a/README.md b/README.md index 45f7775d2..812f591df 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,32 @@ Then run `yarn start` to start the app, using the proxy conf to reroute calls to The application will be available on http://localhost:4200 +### External services +The application stores uploaded documents in Google Cloud Storage. This means that the application +needs credentials (which are json files generated by Google) in order to work fine. + +Two separate accounts, and thus credentials files, are used: + + - one for development + - one for production + +We expect to stay inside the "Always Free" tier, whatever the account is. + +Here are various interesting links regarding Google Cloud Storage (GCS): + + - [The landing page of GCS](https://cloud.google.com/storage/) + - [The pricing page of GCS](https://cloud.google.com/storage/pricing). It describes the conditions + of the "Always Free" tier. The most importtant part being that (as of now), the only regions + eligible to this free tier are `us-west1`, `us-central1`, and `us-east1`. This means that the buckets + created for the application should be created in one of these 3 regions. We currently use only one bucket + (for each account - dev and prod) + - [The Google Cloud console](https://console.cloud.google.com/storage): Make sure to select the appropriate + Google account when visiting it, and to select the `globe42` project. It allows creating the bucket, + browsing and deleting the files, creating credentials (service accounts), etc. + - [The documentation](https://cloud.google.com/storage/docs/), and + [the javadoc of the Java client library](https://googlecloudplatform.github.io/google-cloud-java/0.23.1/apidocs/index.html) + ## Build To build the app, just run: @@ -52,13 +77,21 @@ This will build a standalone jar at `backend/build/libs/globe42.jar`, that you c java -jar backend/build/libs/globe42.jar --globe42.secretKey= - To start the application with the demo profile, add this command-line option: --spring.profiles.active=demo And the full app runs on http://localhost:9000 +By default, the default GCS credentials are used when launching the app this way. That means +that the GCS APIs won't be accessible unless you set the `GOOGLE_APPLICATION_CREDENTIALS` as +described in [the documentation about default credentials](https://developers.google.com/identity/protocols/application-default-credentials#howtheywork) + +To avoid setting a global environment variable, you can instead use this command-line option: + + --globe42.googleCloudStorageCredentialsPath=secrets/google-cloud-storage-dev.json + +This credentials file is located in [the Ninja Squad Drive](https://drive.google.com/drive/u/1/folders/0B0FLWwufPzrTN1NVTDZJMWZTVXc) ## Deployment on CleverCloud @@ -82,4 +115,9 @@ That's it! see the [logs console](https://console.clever-cloud.com/organisations/orga_dd753560-9dfe-4c93-a891-c639d138354b/applications/app_5e422400-281d-499b-b34c-7555c2f7fadd/logs) and cross your fingers ;-) +### Google Cloud Storage credentials on CleverCloud + +Applications on CleverCloud don't have access to the file system. So, instead of defining an environment variable +containing the path of the GCS credentials, we use an environment variable, `globe42.googleCloudStorageCredentials`, +containing the *content* of the production credentials file. diff --git a/backend/build.gradle b/backend/build.gradle index a0b006a64..c3a7ebfff 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -46,16 +46,20 @@ bootJar { bootRun { args '--spring.profiles.active=demo' args '--globe42.secretKey=QMwbcwa19VV02Oy5T7LSWyV+/wZrOsRRfhCR6TkapsY=' + args '--globe42.googleCloudStorageCredentialsPath=' + rootProject.file('secrets/google-cloud-storage-dev.json') } dependencies { compile 'org.springframework.boot:spring-boot-starter-data-jpa' compile 'org.springframework.boot:spring-boot-starter-web' + compile 'io.jsonwebtoken:jjwt:0.7.0' + compile 'com.google.cloud:google-cloud-storage:1.5.1' + testCompile 'org.springframework.boot:spring-boot-starter-test' testCompile 'com.ninja-squad:DbSetup:2.1.0' + runtime 'org.postgresql:postgresql:9.4.1212' runtime 'org.flywaydb:flyway-core' - compile 'io.jsonwebtoken:jjwt:0.7.0' } // remove default tasks added by flyway plugin diff --git a/backend/src/main/java/org/globe42/storage/FileDTO.java b/backend/src/main/java/org/globe42/storage/FileDTO.java new file mode 100644 index 000000000..597f059f1 --- /dev/null +++ b/backend/src/main/java/org/globe42/storage/FileDTO.java @@ -0,0 +1,62 @@ +package org.globe42.storage; + +import java.time.Instant; + +import com.google.cloud.storage.BlobInfo; + +/** + * Information about a file (stored in a Google Cloud Storage) + * @author JB Nizet + */ +public final class FileDTO { + + /** + * The name of the file + */ + private final String name; + + /** + * The size of the file, in bytes + */ + private final Long size; + + /** + * The instant when the file was created in the storage + */ + private final Instant creationInstant; + + /** + * The content type of the file + */ + private final String contentType; + + public FileDTO(BlobInfo blob, String prefix) { + this.name = blob.getName().substring(prefix.length()); + this.size = blob.getSize(); + this.creationInstant = blob.getCreateTime() == null ? Instant.now() : Instant.ofEpochMilli(blob.getCreateTime()); + this.contentType = blob.getContentType(); + } + + public FileDTO(String name, Long size, Instant creationInstant, String contentType) { + this.name = name; + this.size = size; + this.creationInstant = creationInstant; + this.contentType = contentType; + } + + public String getName() { + return name; + } + + public Long getSize() { + return size; + } + + public Instant getCreationInstant() { + return creationInstant; + } + + public String getContentType() { + return contentType; + } +} diff --git a/backend/src/main/java/org/globe42/storage/ReadableFile.java b/backend/src/main/java/org/globe42/storage/ReadableFile.java new file mode 100644 index 000000000..fbce3a132 --- /dev/null +++ b/backend/src/main/java/org/globe42/storage/ReadableFile.java @@ -0,0 +1,28 @@ +package org.globe42.storage; + +import java.io.InputStream; +import java.nio.channels.Channels; + +import com.google.cloud.storage.Blob; + +/** + * A file that can be read + * @author JB Nizet + */ +public class ReadableFile { + private final FileDTO file; + private final Blob blob; + + public ReadableFile(Blob blob, String prefix) { + this.blob = blob; + this.file = new FileDTO(blob, prefix); + } + + public InputStream getInputStream() { + return Channels.newInputStream(this.blob.reader()); + } + + public FileDTO getFile() { + return file; + } +} diff --git a/backend/src/main/java/org/globe42/storage/StorageConfig.java b/backend/src/main/java/org/globe42/storage/StorageConfig.java new file mode 100644 index 000000000..4ee1aa8a2 --- /dev/null +++ b/backend/src/main/java/org/globe42/storage/StorageConfig.java @@ -0,0 +1,78 @@ +package org.globe42.storage; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Config class for Google Cloud Storage beans. See the README for useful links regarding Google Cloud Storage. + * @author JB Nizet + */ +@Configuration +public class StorageConfig { + + private static final Logger LOGGER = LoggerFactory.getLogger(StorageConfig.class); + + /** + * The JSON string containing the credentials, loaded from the property + * globe42.googleCloudStorageCredentials. If not + * null (typically in production, on clever cloud, where the whole JSON credentials are stored in an environment + * variable) then this is used as the source of the google credentials. + */ + private final String credentials; + + /** + * The path to the json file containing the credentials, loaded from the property + * globe42.googleCloudStorageCredentialsPath. Only used if {@link #credentials} is null. + * Typically used in dev mode, where specifying a file path in a command-line property is easier. + */ + private final File credentialsPath; + + public StorageConfig(@Value("${globe42.googleCloudStorageCredentials:#{null}}") String credentials, + @Value("${globe42.googleCloudStorageCredentialsPath:#{null}}") File credentialsPath) { + this.credentials = credentials; + this.credentialsPath = credentialsPath; + } + + @Bean + public Storage storage() throws IOException { + if (this.credentials != null) { + LOGGER.info("Property globe42.googleCloudStorageCredentials is set." + + " Using its value as Google Cloud Storage JSON credentials"); + InputStream in = new ByteArrayInputStream(this.credentials.getBytes(StandardCharsets.UTF_8)); + return StorageOptions + .newBuilder() + .setCredentials(GoogleCredentials.fromStream(in)) + .build() + .getService(); + } + else if (this.credentialsPath != null) { + LOGGER.info("Property globe42.googleCloudStorageCredentialsPath is set." + + " Using its value as a JSON file path to the Google Cloud Storage credentials"); + try (InputStream in = new FileInputStream(this.credentialsPath)) { + return StorageOptions + .newBuilder() + .setCredentials(GoogleCredentials.fromStream(in)) + .build() + .getService(); + } + } + else { + LOGGER.warn("Neither property globe42.googleCloudStorageCredentials nor globe42.googleCloudStorageCredentials is set." + + " Using default instance credentials."); + return StorageOptions.getDefaultInstance().getService(); + } + } +} diff --git a/backend/src/main/java/org/globe42/storage/StorageService.java b/backend/src/main/java/org/globe42/storage/StorageService.java new file mode 100644 index 000000000..67e4506b4 --- /dev/null +++ b/backend/src/main/java/org/globe42/storage/StorageService.java @@ -0,0 +1,93 @@ +package org.globe42.storage; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.channels.Channels; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import com.google.api.gax.paging.Page; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.google.common.io.ByteStreams; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +/** + * Service used to wrap the Google cloud storage API + * @author JB Nizet + */ +@Service +public class StorageService { + + static final String PERSON_FILES_BUCKET = "personfiles"; + + private final Storage storage; + + public StorageService(Storage storage) { + this.storage = storage; + } + + /** + * Lists the files in the given directory + * @param directory the directory (serving as a prefix) to list the files + * @return the list of files in the given directory. The directory prefix is stripped of from the names of the + * returned files + */ + public List list(String directory) { + final String prefix = toPrefix(directory); + Page page = storage.list(PERSON_FILES_BUCKET, + Storage.BlobListOption.pageSize(10_000), + Storage.BlobListOption.currentDirectory(), + Storage.BlobListOption.prefix(prefix)); + return StreamSupport.stream(page.getValues().spliterator(), false) + .map(blob -> new FileDTO(blob, prefix)) + .collect(Collectors.toList()); + } + + /** + * Gets the given file in the given directory + * @param directory the directory (serving as a prefix) of the file + * @return the file in the given directory. The directory prefix is stripped of from the name of the + * returned file + */ + public ReadableFile get(String directory, String name) { + String prefix = toPrefix(directory); + Blob blob = storage.get(PERSON_FILES_BUCKET, prefix + name); + return new ReadableFile(blob, prefix); + } + + public FileDTO create(String directory, + String name, + String contentType, + InputStream data) { + String prefix = toPrefix(directory); + BlobId blobId = BlobId.of(PERSON_FILES_BUCKET, prefix + name); + if (contentType == null) { + contentType = MediaType.APPLICATION_OCTET_STREAM.toString(); + } + BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType(contentType).build(); + try (OutputStream out = Channels.newOutputStream(storage.writer(blobInfo))) { + ByteStreams.copy(data, out); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + + return new FileDTO(blobInfo, prefix); + } + + public void delete(String directory, String name) { + String prefix = toPrefix(directory); + storage.delete(PERSON_FILES_BUCKET, prefix + name); + } + + private String toPrefix(String directory) { + return directory.endsWith("/") ? directory : directory + "/"; + } +} diff --git a/backend/src/main/java/org/globe42/web/AsyncConfig.java b/backend/src/main/java/org/globe42/web/AsyncConfig.java new file mode 100644 index 000000000..638d9f82a --- /dev/null +++ b/backend/src/main/java/org/globe42/web/AsyncConfig.java @@ -0,0 +1,23 @@ +package org.globe42.web; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Configuration class used to configure a thread pool used for asynchronous request processing (file downloads) + * @author JB Nizet + */ +@Configuration +public class AsyncConfig implements WebMvcConfigurer { + @Override + public void configureAsyncSupport(AsyncSupportConfigurer configurer) { + ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); + taskExecutor.setCorePoolSize(1); + taskExecutor.setMaxPoolSize(30); + taskExecutor.setThreadNamePrefix("GlobeWebAsync"); + taskExecutor.initialize(); + configurer.setTaskExecutor(taskExecutor); + } +} diff --git a/backend/src/main/java/org/globe42/web/SuffixMatchingConfig.java b/backend/src/main/java/org/globe42/web/SuffixMatchingConfig.java new file mode 100644 index 000000000..8753c102a --- /dev/null +++ b/backend/src/main/java/org/globe42/web/SuffixMatchingConfig.java @@ -0,0 +1,18 @@ +package org.globe42.web; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Config class used to disable suffix matching, which prevents URLs to `/api/persons/1000/files/foo.png` to + * be downloaded because Spring considers the path is `/api/persons/1000/files/foo`. + * @author JB Nizet + */ +@Configuration +public class SuffixMatchingConfig implements WebMvcConfigurer { + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.setUseSuffixPatternMatch(false); + } +} diff --git a/backend/src/main/java/org/globe42/web/persons/PersonFileController.java b/backend/src/main/java/org/globe42/web/persons/PersonFileController.java new file mode 100644 index 000000000..ab07c0cd4 --- /dev/null +++ b/backend/src/main/java/org/globe42/web/persons/PersonFileController.java @@ -0,0 +1,88 @@ +package org.globe42.web.persons; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import javax.transaction.Transactional; + +import com.google.common.io.ByteStreams; +import org.globe42.dao.PersonDao; +import org.globe42.domain.Person; +import org.globe42.storage.FileDTO; +import org.globe42.storage.ReadableFile; +import org.globe42.storage.StorageService; +import org.globe42.web.exception.NotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +/** + * REST controller used to handle files of a person + * @author JB Nizet + */ +@RestController +@RequestMapping(value = "/api/persons/{personId}/files") +@Transactional +public class PersonFileController { + + private final PersonDao personDao; + private final StorageService storageService; + + public PersonFileController(PersonDao personDao, StorageService storageService) { + this.personDao = personDao; + this.storageService = storageService; + } + + @GetMapping + public List list(@PathVariable("personId") Long personId) { + Person person = personDao.findById(personId).orElseThrow(NotFoundException::new); + String directory = person.getId().toString(); + return storageService.list(directory); + } + + @GetMapping("/{fileName}") + public ResponseEntity get(@PathVariable("personId") Long personId, + @PathVariable("fileName") String fileName) { + Person person = personDao.findById(personId).orElseThrow(NotFoundException::new); + String directory = person.getId().toString(); + ReadableFile readableFile = storageService.get(directory, fileName); + return ResponseEntity.status(HttpStatus.OK) + .contentLength(readableFile.getFile().getSize()) + .contentType(MediaType.valueOf(readableFile.getFile().getContentType())) + .body(outputStream -> { + try (InputStream in = readableFile.getInputStream()) { + ByteStreams.copy(in, outputStream); + } + }); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public FileDTO create(@PathVariable("personId") Long personId, + @RequestParam("file") MultipartFile multipartFile) throws IOException { + Person person = personDao.findById(personId).orElseThrow(NotFoundException::new); + String directory = person.getId().toString(); + return storageService.create(directory, + multipartFile.getOriginalFilename(), + multipartFile.getContentType(), + multipartFile.getInputStream()); + } + + @DeleteMapping("/{fileName}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable("personId") Long personId, @PathVariable("fileName") String fileName) { + Person person = personDao.findById(personId).orElseThrow(NotFoundException::new); + String directory = person.getId().toString(); + storageService.delete(directory, fileName); + } +} diff --git a/backend/src/main/java/org/globe42/web/security/AuthenticationInterceptor.java b/backend/src/main/java/org/globe42/web/security/AuthenticationInterceptor.java index 20e58f190..7260a9e56 100644 --- a/backend/src/main/java/org/globe42/web/security/AuthenticationInterceptor.java +++ b/backend/src/main/java/org/globe42/web/security/AuthenticationInterceptor.java @@ -1,5 +1,7 @@ package org.globe42.web.security; +import java.util.Arrays; +import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -36,17 +38,11 @@ public boolean preHandle(HttpServletRequest request, } private Long extractUserIdFromToken(HttpServletRequest request) { - String header = request.getHeader(HttpHeaders.AUTHORIZATION); - if (header == null) { - return null; - } - - if (!header.startsWith(BEARER_PREFIX)) { + String token = extractToken(request); + if (token == null) { return null; } - String token = header.substring(BEARER_PREFIX.length()).trim(); - try { Claims claims = jwtHelper.extractClaims(token); return Long.parseLong(claims.getSubject()); @@ -55,4 +51,24 @@ private Long extractUserIdFromToken(HttpServletRequest request) { throw new UnauthorizedException(); } } + + private String extractToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null) { + if (!header.startsWith(BEARER_PREFIX)) { + return null; + } + return header.substring(BEARER_PREFIX.length()).trim(); + } + else if (request.getCookies() != null) { + return Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals("globe42_token")) + .map(Cookie::getValue) + .findAny() + .orElse(null); + } + else { + return null; + } + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8cd896d3a..525a38a70 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,9 +19,13 @@ spring: password: globe42 hikari: maximum-pool-size: 5 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 20MB --- spring: profiles: demo flyway: - locations: classpath:/db/migration,classpath:/demo/db/migration \ No newline at end of file + locations: classpath:/db/migration,classpath:/demo/db/migration diff --git a/backend/src/test/java/org/globe42/storage/StorageServiceTest.java b/backend/src/test/java/org/globe42/storage/StorageServiceTest.java new file mode 100644 index 000000000..75dd4e2b0 --- /dev/null +++ b/backend/src/test/java/org/globe42/storage/StorageServiceTest.java @@ -0,0 +1,140 @@ +package org.globe42.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import com.google.api.gax.paging.Page; +import com.google.cloud.ReadChannel; +import com.google.cloud.WriteChannel; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.google.common.io.ByteStreams; +import org.globe42.test.BaseTest; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +/** + * Unit tests for {@link StorageService} + * @author JB Nizet + */ +public class StorageServiceTest extends BaseTest { + @Mock + private Storage mockStorage; + + @InjectMocks + private StorageService service; + + private Blob blob; + + @Before + public void prepare() { + blob = mock(Blob.class); + doReturn("foo/hello.txt").when(blob).getName(); + doReturn(5L).when(blob).getSize(); + doReturn("text/plain").when(blob).getContentType(); + long createTime = System.currentTimeMillis() - 100000L; + doReturn(createTime).when(blob).getCreateTime(); + } + + @SuppressWarnings("unchecked") + @Test + public void shouldList() { + Page mockPage = mock(Page.class); + when(mockPage.getValues()).thenReturn(Collections.singletonList(blob)); + + when(mockStorage.list(StorageService.PERSON_FILES_BUCKET, + Storage.BlobListOption.pageSize(10_000), + Storage.BlobListOption.currentDirectory(), + Storage.BlobListOption.prefix("foo/"))).thenReturn(mockPage); + + List result = service.list("foo"); + + assertThat(result).hasSize(1); + FileDTO file = result.get(0); + assertThat(file.getContentType()).isEqualTo(blob.getContentType()); + assertThat(file.getSize()).isEqualTo(blob.getSize()); + assertThat(file.getCreationInstant()).isEqualTo(Instant.ofEpochMilli(blob.getCreateTime())); + assertThat(file.getName()).isEqualTo("hello.txt"); + } + + @Test + public void shouldGet() throws IOException { + when(mockStorage.get(StorageService.PERSON_FILES_BUCKET, blob.getName())).thenReturn(blob); + + ReadChannel mockChannel = mock(ReadChannel.class); + doReturn(mockChannel).when(blob).reader(); + + when(mockChannel.read(any(ByteBuffer.class))).thenAnswer(new FakeReadAnswer()); + + ReadableFile result = service.get("foo", "hello.txt"); + + FileDTO file = result.getFile(); + assertThat(file.getContentType()).isEqualTo(blob.getContentType()); + assertThat(file.getSize()).isEqualTo(blob.getSize()); + assertThat(file.getCreationInstant()).isEqualTo(Instant.ofEpochMilli(blob.getCreateTime())); + assertThat(file.getName()).isEqualTo("hello.txt"); + byte[] bytes = ByteStreams.toByteArray(result.getInputStream()); + assertThat(bytes).isEqualTo("hello".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void shouldCreate() throws IOException { + BlobInfo blobInfo = + BlobInfo.newBuilder(StorageService.PERSON_FILES_BUCKET, "foo/new.txt") + .setContentType("text/plain") + .build(); + + byte[] written = new byte[7]; + WriteChannel mockWriteChannel = mock(WriteChannel.class); + when(mockWriteChannel.write(any(ByteBuffer.class))).thenAnswer(invocation -> { + ByteBuffer byteBuffer = invocation.getArgument(0); + byteBuffer.get(written, 0, byteBuffer.limit()); + return written.length; + }); + when(mockStorage.writer(blobInfo)).thenReturn(mockWriteChannel); + + FileDTO result = service.create("foo", "new.txt", "text/plain", new ByteArrayInputStream("goodbye".getBytes(StandardCharsets.UTF_8))); + assertThat(result.getName()).isEqualTo("new.txt"); + assertThat(result.getCreationInstant()).isNotNull(); + assertThat(result.getContentType()).isEqualTo("text/plain"); + assertThat(written).isEqualTo("goodbye".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void shouldDelete() { + service.delete("foo", "hello.txt"); + + verify(mockStorage).delete(StorageService.PERSON_FILES_BUCKET, "foo/hello.txt"); + } + + private static class FakeReadAnswer implements Answer { + private int count = 0; + + @Override + public Integer answer(InvocationOnMock invocation) throws Throwable { + if (count == 0) { + ByteBuffer buffer = invocation.getArgument(0); + buffer.put("hello".getBytes(StandardCharsets.UTF_8)); + count++; + return "hello".length(); + } + else { + return -1; // end of stream + } + } + } +} diff --git a/backend/src/test/java/org/globe42/web/persons/PersonFileControllerMvcTest.java b/backend/src/test/java/org/globe42/web/persons/PersonFileControllerMvcTest.java new file mode 100644 index 000000000..d9cfdc405 --- /dev/null +++ b/backend/src/test/java/org/globe42/web/persons/PersonFileControllerMvcTest.java @@ -0,0 +1,94 @@ +package org.globe42.web.persons; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Optional; + +import org.globe42.dao.PersonDao; +import org.globe42.domain.Person; +import org.globe42.storage.FileDTO; +import org.globe42.storage.ReadableFile; +import org.globe42.storage.StorageService; +import org.globe42.test.GlobeMvcTest; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * MVC tests for {@link PersonFileController} + * @author JB Nizet + */ +@RunWith(SpringRunner.class) +@GlobeMvcTest(PersonFileController.class) +public class PersonFileControllerMvcTest { + + @MockBean + private StorageService mockStorageService; + + @MockBean + private PersonDao mockPersonDao; + + @Autowired + private MockMvc mvc; + + private Person person; + private String directory; + + @Before + public void prepare() { + person = new Person(1000L); + when(mockPersonDao.findById(person.getId())).thenReturn(Optional.of(person)); + directory = Long.toString(person.getId()); + } + + @Test + public void shouldCreate() throws Exception { + FileDTO file = new FileDTO("new.txt", 3L, Instant.now(), "text/plain"); + MockMultipartFile multipartFile = + new MockMultipartFile("file", "new.txt", "text/plain", "new".getBytes(StandardCharsets.UTF_8)); + + when(mockStorageService.create(eq(directory), + eq(multipartFile.getOriginalFilename()), + eq(multipartFile.getContentType()), + any(InputStream.class))).thenReturn(file); + + mvc.perform(multipart("/api/persons/{personId}/files", person.getId()) + .file(multipartFile)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("new.txt")); + } + + @Test + public void shouldGet() throws Exception { + FileDTO file = new FileDTO("hello.txt", 5L, Instant.now(), "text/plain"); + ReadableFile readableFile = mock(ReadableFile.class); + when(readableFile.getFile()).thenReturn(file); + when(readableFile.getInputStream()).thenReturn(new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))); + when(mockStorageService.get(directory, file.getName())).thenReturn(readableFile); + + mvc.perform(get("/api/persons/{personId}/files/{name}", person.getId(), file.getName())) + .andDo(MvcResult::getAsyncResult) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.TEXT_PLAIN)) + .andExpect(content().bytes("hello".getBytes(StandardCharsets.UTF_8))) + .andExpect(header().longValue(HttpHeaders.CONTENT_LENGTH, 5L)); + } +} diff --git a/backend/src/test/java/org/globe42/web/persons/PersonFileControllerTest.java b/backend/src/test/java/org/globe42/web/persons/PersonFileControllerTest.java new file mode 100644 index 000000000..ac717e2bb --- /dev/null +++ b/backend/src/test/java/org/globe42/web/persons/PersonFileControllerTest.java @@ -0,0 +1,110 @@ +package org.globe42.web.persons; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.globe42.dao.PersonDao; +import org.globe42.domain.Person; +import org.globe42.storage.FileDTO; +import org.globe42.storage.ReadableFile; +import org.globe42.storage.StorageService; +import org.globe42.test.BaseTest; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +/** + * Unit tests for {@link PersonFileController} + * @author JB Nizet + */ +public class PersonFileControllerTest extends BaseTest { + @Mock + private PersonDao mockPersonDao; + + @Mock + private StorageService mockStorageService; + + @InjectMocks + private PersonFileController controller; + + private Person person; + private String directory; + + @Before + public void prepare() { + person = new Person(1000L); + when(mockPersonDao.findById(person.getId())).thenReturn(Optional.of(person)); + directory = Long.toString(person.getId()); + } + + @Test + public void shouldList() { + FileDTO file = new FileDTO("hello.txt", 5L, Instant.now(), "text/plain"); + when(mockStorageService.list(directory)).thenReturn(Collections.singletonList(file)); + + List result = controller.list(person.getId()); + + assertThat(result).containsExactly(file); + } + + @Test + public void shouldGet() throws IOException { + FileDTO file = new FileDTO("hello.txt", 5L, Instant.now(), "text/plain"); + ReadableFile readableFile = mock(ReadableFile.class); + when(readableFile.getFile()).thenReturn(file); + when(readableFile.getInputStream()).thenReturn(new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))); + when(mockStorageService.get(directory, file.getName())).thenReturn(readableFile); + + ResponseEntity result = controller.get(person.getId(), file.getName()); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_PLAIN); + assertThat(result.getHeaders().getContentLength()).isEqualTo(file.getSize()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + result.getBody().writeTo(out); + assertThat(out.toByteArray()).isEqualTo("hello".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void shouldCreate() throws IOException { + MultipartFile multipartFile = + new MockMultipartFile("file", "new.txt", "text/plain", "new".getBytes(StandardCharsets.UTF_8)); + FileDTO file = new FileDTO("new.txt", 3L, Instant.now(), "text/plain"); + + when(mockStorageService.create(eq(directory), + eq(multipartFile.getOriginalFilename()), + eq(multipartFile.getContentType()), + any(InputStream.class))).thenReturn(file); + + FileDTO result = controller.create(person.getId(), multipartFile); + + assertThat(result).isEqualTo(file); + } + + @Test + public void shouldDelete() throws IOException { + controller.delete(person.getId(), "hello.txt"); + verify(mockStorageService).delete(directory, "hello.txt"); + } +} diff --git a/backend/src/test/java/org/globe42/web/security/AuthenticationInterceptorTest.java b/backend/src/test/java/org/globe42/web/security/AuthenticationInterceptorTest.java index 7d928f635..bc39a4a33 100644 --- a/backend/src/test/java/org/globe42/web/security/AuthenticationInterceptorTest.java +++ b/backend/src/test/java/org/globe42/web/security/AuthenticationInterceptorTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import javax.servlet.http.Cookie; + import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.impl.DefaultClaims; @@ -31,7 +33,7 @@ public class AuthenticationInterceptorTest extends BaseTest { private AuthenticationInterceptor interceptor; @Test(expected = UnauthorizedException.class) - public void shouldThrowIfNoHeader() { + public void shouldThrowIfNoHeaderAndNoCookie() { MockHttpServletRequest request = new MockHttpServletRequest(); interceptor.preHandle(request, null, null); } @@ -66,7 +68,7 @@ public void shouldThrowIfHeaderWithTokenWithBadUserId() { } @Test - public void shouldSetCurrentUserIfValidToken() { + public void shouldSetCurrentUserIfValidTokenInHeader() { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer hello"); @@ -78,4 +80,18 @@ public void shouldSetCurrentUserIfValidToken() { assertThat(currentUser.getUserId()).isEqualTo(1234L); } + + @Test + public void shouldSetCurrentUserIfValidTokenInCookie() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie("globe42_token", "hello")); + + Claims claims = new DefaultClaims(); + claims.setSubject("1234"); + when(mockJwtHelper.extractClaims("hello")).thenReturn(claims); + + interceptor.preHandle(request, null, null); + + assertThat(currentUser.getUserId()).isEqualTo(1234L); + } } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 592022f7a..aefc49575 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -71,6 +71,9 @@ import { NoteComponent } from './note/note.component'; import { PersonNoteService } from './person-note.service'; import { PersonNotesComponent } from './person-notes/person-notes.component'; import { FrenchDateParserFormatterService } from './french-date-parser-formatter.service'; +import { PersonFilesComponent } from './person-files/person-files.component'; +import { PersonFileService } from './person-file.service'; +import { FileSizePipe } from './file-size.pipe'; @NgModule({ declarations: [ @@ -111,7 +114,9 @@ import { FrenchDateParserFormatterService } from './french-date-parser-formatter TasksPageComponent, TaskEditComponent, NoteComponent, - PersonNotesComponent + PersonNotesComponent, + PersonFilesComponent, + FileSizePipe ], entryComponents: [ ConfirmModalContentComponent @@ -166,7 +171,8 @@ import { FrenchDateParserFormatterService } from './french-date-parser-formatter TaskService, TaskResolverService, NowService, - PersonNoteService + PersonNoteService, + PersonFileService ], bootstrap: [AppComponent] }) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 2831d9734..269097120 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -29,11 +29,11 @@ import { PersonIncomeEditComponent } from './person-income-edit/person-income-ed import { PersonFamilySituationComponent } from './person-family-situation/person-family-situation.component'; import { CitiesUploadComponent } from './cities-upload/cities-upload.component'; import { TasksLayoutComponent } from './tasks-layout/tasks-layout.component'; -import { TasksComponent } from './tasks/tasks.component'; import { TasksResolverService } from './tasks-resolver.service'; import { TasksPageComponent } from './tasks-page/tasks-page.component'; import { TaskEditComponent } from './task-edit/task-edit.component'; import { TaskResolverService } from './task-resolver.service'; +import { PersonFilesComponent } from './person-files/person-files.component'; export const routes: Routes = [ { path: '', component: HomeComponent }, @@ -75,7 +75,8 @@ export const routes: Routes = [ tasks: TasksResolverService }, runGuardsAndResolvers: 'paramsOrQueryParamsChange' - } + }, + { path: 'files', component: PersonFilesComponent } ] }, { diff --git a/frontend/src/app/file-size.pipe.spec.ts b/frontend/src/app/file-size.pipe.spec.ts new file mode 100644 index 000000000..03fd38682 --- /dev/null +++ b/frontend/src/app/file-size.pipe.spec.ts @@ -0,0 +1,17 @@ +import { FileSizePipe } from './file-size.pipe'; + +describe('FileSizePipe', () => { + it('should format', () => { + const pipe = new FileSizePipe('fr-FR'); + expect(pipe.transform(null)).toBe(''); + expect(pipe.transform(undefined)).toBe(''); + expect(pipe.transform(0)).toBe('0\u00a0o'); + expect(pipe.transform(999)).toBe('999\u00a0o'); + expect(pipe.transform(1000)).toBe('1\u00a0Ko'); + expect(pipe.transform(1501)).toBe('1,5\u00a0Ko'); + expect(pipe.transform(1000000)).toBe('1\u00a0Mo'); + expect(pipe.transform(1500000)).toBe('1,5\u00a0Mo'); + expect(pipe.transform(1000000000)).toBe('1\u00a0Go'); + expect(pipe.transform(1000000000000)).toBe('1\u00a0000\u00a0Go'); + }); +}); diff --git a/frontend/src/app/file-size.pipe.ts b/frontend/src/app/file-size.pipe.ts new file mode 100644 index 000000000..209f273cc --- /dev/null +++ b/frontend/src/app/file-size.pipe.ts @@ -0,0 +1,32 @@ +import { Inject, LOCALE_ID, Pipe, PipeTransform } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; + +const UNITS = ['o', 'Ko', 'Mo', 'Go']; +const KILO = 1000; + +@Pipe({ + name: 'fileSize' +}) +export class FileSizePipe implements PipeTransform { + + private decimalPipe: DecimalPipe; + + constructor(@Inject(LOCALE_ID) locale: string) { + this.decimalPipe = new DecimalPipe(locale); + } + + transform(value: number): any { + if (value === null || value === undefined) { + return ''; + } + let multiplicator = 1; + for (let i = 0; i < UNITS.length; i++) { + const limit = multiplicator * KILO; + if (value < limit || i === UNITS.length - 1) { + const displayedValue = value / multiplicator; + return `${this.decimalPipe.transform(displayedValue, '1.0-1')}\u00A0${UNITS[i]}`; + } + multiplicator = limit; + } + } +} diff --git a/frontend/src/app/models/file.model.ts b/frontend/src/app/models/file.model.ts new file mode 100644 index 000000000..9bd8e220f --- /dev/null +++ b/frontend/src/app/models/file.model.ts @@ -0,0 +1,6 @@ +export interface FileModel { + name: string; + size: number; + creationInstant: string; + contentType: string; +} diff --git a/frontend/src/app/person-file.service.spec.ts b/frontend/src/app/person-file.service.spec.ts new file mode 100644 index 000000000..26dcc28b8 --- /dev/null +++ b/frontend/src/app/person-file.service.spec.ts @@ -0,0 +1,67 @@ +import { PersonFileService } from './person-file.service'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { FileModel } from './models/file.model'; +import { HttpEvent, HttpResponse } from '@angular/common/http'; + +describe('PersonFileService', () => { + let service: PersonFileService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ PersonFileService ], + imports: [ HttpClientTestingModule ] + }); + + service = TestBed.get(PersonFileService); + http = TestBed.get(HttpTestingController); + }); + + it('should list person files', () => { + const expected: Array = []; + + let actual: Array; + service.list(1).subscribe(files => actual = files); + + http.expectOne({url: '/api/persons/1/files', method: 'GET'}).flush(expected); + expect(actual).toEqual(expected); + }); + + it('should give the url of a file', () => { + expect(service.url(1, 'test.txt')).toBe('/api/persons/1/files/test.txt'); + expect(service.url(1, 'test 1.txt')).toBe('/api/persons/1/files/test%201.txt'); + }); + + it('should create a file', () => { + const file: File = { + name: 'test 1.txt', + type: 'text/plain', + size: 1234 + } as File; + + let actual: HttpEvent = null; + service.create(1, file).subscribe(event => actual = event); + + const testRequest = http.expectOne({ url: '/api/persons/1/files', method: 'POST' }); + const body: FormData = testRequest.request.body; + expect(body.has('file')).toBeTruthy(); + + const expected: FileModel = { + name: 'test 1.txt', + size: 1234, + contentType: 'text/plain', + creationInstant: '2017-09-18T08:50:00.000Z' + }; + testRequest.flush(expected); + + expect((actual as HttpResponse).body).toEqual(expected); + }); + + it('should delete a file', () => { + let ok = false; + service.delete(1, 'test 1.txt').subscribe(() => ok = true); + http.expectOne({ url: '/api/persons/1/files/test%201.txt', method: 'DELETE' }).flush(null); + expect(ok).toBe(true); + }); +}); diff --git a/frontend/src/app/person-file.service.ts b/frontend/src/app/person-file.service.ts new file mode 100644 index 000000000..2e5b0e3ac --- /dev/null +++ b/frontend/src/app/person-file.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpEvent, HttpRequest } from '@angular/common/http'; +import { FileModel } from './models/file.model'; +import { Observable } from 'rxjs/Observable'; + +@Injectable() +export class PersonFileService { + + constructor(private http: HttpClient) { } + + list(personId: number): Observable> { + return this.http.get>(`/api/persons/${personId}/files`); + } + + url(personId: number, name: string) { + const encodedName = encodeURIComponent(name); + return `/api/persons/${personId}/files/${encodedName}`; + } + + delete(personId: number, name: string): Observable { + return this.http.delete(this.url(personId, name)); + } + + create(personId: number, file: File): Observable> { + const formData = new FormData(); + formData.append('file', file); + + const req = new HttpRequest('POST', `/api/persons/${personId}/files`, formData, { + reportProgress: true, + }); + + return this.http.request(req); + } +} diff --git a/frontend/src/app/person-files/person-files.component.html b/frontend/src/app/person-files/person-files.component.html new file mode 100644 index 000000000..d3a574ea3 --- /dev/null +++ b/frontend/src/app/person-files/person-files.component.html @@ -0,0 +1,37 @@ +
+ + Chargement... +
+ + +
+ +
+ Aucun document +
+ +
+ +
{{ file.creationInstant | date }}
+
{{ file.size | fileSize }}
+
+ +
+
+
+ +
+
+ +
+
+ + {{ uploadProgress | percent:'1.0-0' }} + +
+
diff --git a/frontend/src/app/person-files/person-files.component.scss b/frontend/src/app/person-files/person-files.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/person-files/person-files.component.spec.ts b/frontend/src/app/person-files/person-files.component.spec.ts new file mode 100644 index 000000000..288750f89 --- /dev/null +++ b/frontend/src/app/person-files/person-files.component.spec.ts @@ -0,0 +1,178 @@ +import { PersonFilesComponent } from './person-files.component'; +import { async, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { HttpClientModule, HttpEventType, HttpResponse } from '@angular/common/http'; +import { NgbModule, NgbProgressbar } from '@ng-bootstrap/ng-bootstrap'; +import { FileModel } from '../models/file.model'; +import { PersonModel } from '../models/person.model'; +import { PersonFileService } from '../person-file.service'; +import { ConfirmService } from '../confirm.service'; +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import { FileSizePipe } from '../file-size.pipe'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute } from '@angular/router'; +import { Subject } from 'rxjs/Subject'; +import { By } from '@angular/platform-browser'; + +describe('PersonFilesComponent', () => { + const person = { id: 42 } as PersonModel; + let files: Array; + + const activatedRoute = { + parent: { + snapshot: { + data: { + person + } + } + } + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule, RouterTestingModule, NgbModule.forRoot()], + declarations: [PersonFilesComponent, FileSizePipe], + providers: [ + PersonFileService, + ConfirmService, + { provide: ActivatedRoute, useValue: activatedRoute }, + ] + }); + + files = [ + { + name: 'file1.txt', + creationInstant: '2017-08-09T12:00:00.000Z', + size: 1000, + }, + { + name: 'file2.txt', + creationInstant: '2017-08-10T12:00:00.000Z', + size: 1000000 + } + ] as Array; + })); + + it('should display files', () => { + const personFileService = TestBed.get(PersonFileService); + spyOn(personFileService, 'list').and.returnValue(Observable.of(files)); + + const fixture = TestBed.createComponent(PersonFilesComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('.file-item').length).toBe(2); + expect(fixture.nativeElement.querySelector('.file-item a').href).toContain('/api/persons/42/files/file2.txt'); + }); + + it('should display no file message if no files', () => { + const personFileService = TestBed.get(PersonFileService); + spyOn(personFileService, 'list').and.returnValue(Observable.of([])); + + const fixture = TestBed.createComponent(PersonFilesComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Aucun document'); + }); + + it('should display a spinner after 300 ms until files are available', fakeAsync(() => { + const personFileService = TestBed.get(PersonFileService); + const subject = new Subject>(); + spyOn(personFileService, 'list').and.returnValue(subject); + + const fixture = TestBed.createComponent(PersonFilesComponent); + fixture.detectChanges(); + tick(); + + expect(fixture.nativeElement.querySelector('.fa-spinner')).toBeFalsy(); + + tick(350); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.fa-spinner')).toBeTruthy(); + + subject.next(files); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.fa-spinner')).toBeFalsy(); + })); + + it('should delete file', () => { + // create component with 2 files + const personFileService = TestBed.get(PersonFileService); + const confirmService = TestBed.get(ConfirmService); + spyOn(confirmService, 'confirm').and.returnValue(Observable.of('ok')); + spyOn(personFileService, 'delete').and.returnValue(Observable.of(null)); + spyOn(personFileService, 'list').and.returnValues(Observable.of(files), Observable.of([files[1]])); + + const fixture = TestBed.createComponent(PersonFilesComponent); + fixture.detectChanges(); + + // delete first file and confirm + fixture.nativeElement.querySelector('.file-item button').click(); + fixture.detectChanges(); + + expect(personFileService.delete).toHaveBeenCalledWith(person.id, files[1].name); + expect(personFileService.list).toHaveBeenCalledWith(person.id); + + expect(fixture.nativeElement.querySelectorAll('.file-item').length).toBe(1); + }); + + it('should upload a file', () => { + // create component with 1 file + const personFileService = TestBed.get(PersonFileService); + spyOn(personFileService, 'list').and.returnValues(Observable.of([files[0]]), Observable.of([files])); + + const fakeEvents = new Subject(); + spyOn(personFileService, 'create').and.returnValues(fakeEvents); + + const fixture = TestBed.createComponent(PersonFilesComponent); + fixture.detectChanges(); + + // trigger change + const fileChangeEvent = { + target: { + files: [{ + name: files[1].name + }] + } + }; + fixture.componentInstance.upload(fileChangeEvent); + fixture.detectChanges(); + + expect(fixture.componentInstance.uploading).toBeTruthy(); + + fakeEvents.next({ + type: HttpEventType.UploadProgress, + loaded: 5, + total: 10 + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.uploadProgress).toBe(5 / 10); + const progressBar: NgbProgressbar = fixture.debugElement.query(By.directive(NgbProgressbar)).componentInstance; + expect(progressBar.value).toBe(5 / 10); + expect(progressBar.max).toBe(1); + + fakeEvents.next({ + type: HttpEventType.UploadProgress, + loaded: 10, + total: 10 + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.uploadProgress).toBe(1); + expect(progressBar.striped).toBeTruthy(); + expect(progressBar.animated).toBeTruthy(); + + // emit response and complete + fakeEvents.next(new HttpResponse({ + body: files[1] + })); + fakeEvents.complete(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('.file-item').length).toBe(2); + }); + }); +}); diff --git a/frontend/src/app/person-files/person-files.component.ts b/frontend/src/app/person-files/person-files.component.ts new file mode 100644 index 000000000..2c0d956a0 --- /dev/null +++ b/frontend/src/app/person-files/person-files.component.ts @@ -0,0 +1,75 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FileModel } from '../models/file.model'; +import { PersonFileService } from '../person-file.service'; +import { PersonModel } from '../models/person.model'; +import { ConfirmService } from '../confirm.service'; +import { sortBy } from '../utils'; +import { HttpEventType } from '@angular/common/http'; +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/fromEvent'; +import 'rxjs/add/observable/forkJoin'; + +@Component({ + selector: 'gl-person-files', + templateUrl: './person-files.component.html', + styleUrls: ['./person-files.component.scss'] +}) +export class PersonFilesComponent implements OnInit { + + private person: PersonModel; + loading = false; + uploading = false; + uploadProgress: number; + files: Array; + + constructor(route: ActivatedRoute, + private personFileService: PersonFileService, + private confirmService: ConfirmService) { + this.person = route.parent.snapshot.data['person']; + } + + ngOnInit(): void { + this.loadFiles(); + } + + url(file: FileModel): string { + return this.personFileService.url(this.person.id, file.name); + } + + delete(file: FileModel) { + this.confirmService.confirm({ + message: 'Voulez-vous vraiment supprimer ce document\u00A0?' + }).switchMap(() => this.personFileService.delete(this.person.id, file.name)) + .subscribe(() => this.loadFiles()); + } + + upload(fileChangeEvent) { + this.uploading = true; + + const file = fileChangeEvent.target.files[0]; + + this.personFileService.create(this.person.id, file) + .finally(() => this.uploading = false) + .subscribe( + progressEvent => { + if (progressEvent.type === HttpEventType.UploadProgress) { + this.uploadProgress = progressEvent.loaded / progressEvent.total; + } + }, + () => {}, + () => this.loadFiles()); + } + + private loadFiles() { + // display the spinner after 300ms, unless the notes have loaded before. Note: Observable.delay is untestable, + // see https://github.com/angular/angular/issues/10127 + window.setTimeout(() => this.loading = !this.files, 300); + + this.personFileService.list(this.person.id) + .subscribe(files => { + this.loading = false; + this.files = sortBy(files, file => file.creationInstant, true); + }); + } +} diff --git a/frontend/src/app/person-layout/person-layout.component.html b/frontend/src/app/person-layout/person-layout.component.html index 37f26c6f0..b5dae9815 100644 --- a/frontend/src/app/person-layout/person-layout.component.html +++ b/frontend/src/app/person-layout/person-layout.component.html @@ -15,6 +15,9 @@

{{ person | fullname }}

+ diff --git a/frontend/src/app/person-layout/person-layout.component.spec.ts b/frontend/src/app/person-layout/person-layout.component.spec.ts index 6a3828d55..e82485b03 100644 --- a/frontend/src/app/person-layout/person-layout.component.spec.ts +++ b/frontend/src/app/person-layout/person-layout.component.spec.ts @@ -41,7 +41,7 @@ describe('PersonLayoutComponent', () => { const nativeElement = fixture.nativeElement; const links = nativeElement.querySelectorAll('a.nav-link'); - expect(links.length).toBe(4); + expect(links.length).toBe(5); const outlet = fixture.debugElement.query(By.directive(RouterOutlet)); expect(outlet).toBeTruthy(); @@ -54,6 +54,6 @@ describe('PersonLayoutComponent', () => { const nativeElement = fixture.nativeElement; const links = nativeElement.querySelectorAll('a.nav-link'); - expect(links.length).toBe(2); + expect(links.length).toBe(3); }); }); diff --git a/frontend/src/app/person-notes/person-notes.component.ts b/frontend/src/app/person-notes/person-notes.component.ts index c2b8daabb..40c6f93aa 100644 --- a/frontend/src/app/person-notes/person-notes.component.ts +++ b/frontend/src/app/person-notes/person-notes.component.ts @@ -34,7 +34,7 @@ export class PersonNotesComponent implements OnInit { ngOnInit(): void { // display the spinner after 300ms, unless the notes have loaded before. Note: Observable.delay is untestable, // see https://github.com/angular/angular/issues/10127 - window.setTimeout(() => this.spinnerDisplayed = !this.notes); + window.setTimeout(() => this.spinnerDisplayed = !this.notes, 300); this.personNoteService.list(this.person.id).subscribe(notes => { this.notes = notes; diff --git a/frontend/src/app/user.service.ts b/frontend/src/app/user.service.ts index c846fd0e5..d08317961 100644 --- a/frontend/src/app/user.service.ts +++ b/frontend/src/app/user.service.ts @@ -28,6 +28,7 @@ export class UserService { storeLoggedInUser(user: UserModel) { window.localStorage.setItem('rememberMe', JSON.stringify(user)); + document.cookie = `globe42_token=${user.token};path=/`; this.userEvents.next(user); } @@ -42,6 +43,7 @@ export class UserService { logout() { this.userEvents.next(null); window.localStorage.removeItem('rememberMe'); + document.cookie = `globe42_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT`; } isLoggedIn(): boolean { diff --git a/frontend/src/app/utils.spec.ts b/frontend/src/app/utils.spec.ts index 8653304cd..65274681b 100644 --- a/frontend/src/app/utils.spec.ts +++ b/frontend/src/app/utils.spec.ts @@ -15,6 +15,20 @@ describe('utils', () => { expect(result).not.toBe(array); }); + it('should sort by in reverse', () => { + const array = [ + { foo: 'b' }, + { foo: 'a' }, + { foo: 'c' }, + { foo: 'a' }, + { foo: 'b' } + ]; + + const result = sortBy(array, o => o.foo, true); + expect(result.map(o => o.foo)).toEqual(['c', 'b', 'b', 'a', 'a']); + expect(result).not.toBe(array); + }); + it('should interpolate', () => { const template = 'Hello ${w}, the ${w} is ${score}/${total} today'; expect(interpolate(template, { w: 'world', score: 9, total: 10 })).toBe( diff --git a/frontend/src/app/utils.ts b/frontend/src/app/utils.ts index 9ce534651..7a54e49dc 100644 --- a/frontend/src/app/utils.ts +++ b/frontend/src/app/utils.ts @@ -1,24 +1,25 @@ +import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; +import { padNumber, toInteger } from '@ng-bootstrap/ng-bootstrap/util/util'; + /** * Creates a sorted copy of an array, by extracting a value from each element using the given extractor, * and comparing the values. * * Usages of this method could be replaced by Lodash's sortBy method */ -import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; -import { padNumber, toInteger } from '@ng-bootstrap/ng-bootstrap/util/util'; - -export function sortBy(array: Array, extractor: (T) => any): Array { +export function sortBy(array: Array, extractor: (T) => any, reverse = false): Array { const result = array.slice(); result.sort((e1, e2) => { const v1 = extractor(e1); const v2 = extractor(e2); + let r = 0; if (v1 < v2) { - return -1; + r = -1; } if (v1 > v2) { - return 1; + r = 1; } - return 0; + return reverse ? - r : r; }); return result; }