From 232518bee48c08c8604567c3dd36b4a6babf83a3 Mon Sep 17 00:00:00 2001 From: Armin Friedl Date: Sat, 25 Jul 2020 13:41:45 +0200 Subject: [PATCH] PUT fling, purify state tree, start settings --- examples/querysheet.http | 11 +- .../fling/controller/FlingController.java | 6 + .../friedl/fling/service/FlingService.java | 20 + .../fling/controller/FlingControllerTest.java | 22 + .../fling/service/FlingServiceTest.java | 35 ++ web/fling/src/components/admin/Settings.jsx | 472 ++++++++---------- web/fling/src/redux/actions.js | 14 +- 7 files changed, 317 insertions(+), 263 deletions(-) diff --git a/examples/querysheet.http b/examples/querysheet.http index cb84cd8..5e08215 100644 --- a/examples/querysheet.http +++ b/examples/querysheet.http @@ -52,8 +52,10 @@ GET http://localhost:8080/api/auth/derive Content-Type: application/json :token +:derivedToken = 56c4ff2e-7da7-4582-bd2c-9a81d9a13abb + # -:flingId = dfc208a3-5924-43b4-aa6a-c263541dca5e +:flingId = 9f7353a3-efaa-41af-9f93-61e02dc5e440 # Get one fling GET http://localhost:8080/api/fling/:flingId @@ -61,8 +63,15 @@ GET http://localhost:8080/api/fling/:flingId # Get all artifacts GET http://localhost:8080/api/fling/:flingId/artifacts +Content-Type: application/json :token +:artifactId = 01ba7fb9-9f2e-4809-9b2b-cbce12a92621 + +# Get artifact data by derived token +GET http://localhost:8080/api/artifacts/:artifactId/data?derivedtoken=:derivedToken +Content-Type: application/json + # GET https://httpbin.org/json -> jq-set-var :my-var .slideshow.slides[0].title diff --git a/service/fling/src/main/java/net/friedl/fling/controller/FlingController.java b/service/fling/src/main/java/net/friedl/fling/controller/FlingController.java index 3037aa2..db4dddc 100644 --- a/service/fling/src/main/java/net/friedl/fling/controller/FlingController.java +++ b/service/fling/src/main/java/net/friedl/fling/controller/FlingController.java @@ -15,6 +15,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -59,6 +60,11 @@ public class FlingController { return flingService.create(flingDto); } + @PutMapping("/{id}") + public FlingDto putFling(@PathVariable UUID id, @RequestBody @Valid FlingDto flingDto) { + return flingService.replace(id, flingDto); + } + @PostMapping("/{id}/artifacts") public ArtifactDto postArtifact(@PathVariable UUID id, @RequestBody @Valid ArtifactDto artifactDto) { diff --git a/service/fling/src/main/java/net/friedl/fling/service/FlingService.java b/service/fling/src/main/java/net/friedl/fling/service/FlingService.java index f2e2fae..a30a9c8 100644 --- a/service/fling/src/main/java/net/friedl/fling/service/FlingService.java +++ b/service/fling/src/main/java/net/friedl/fling/service/FlingService.java @@ -7,6 +7,7 @@ import java.util.Set; import java.util.UUID; import javax.persistence.EntityNotFoundException; import javax.transaction.Transactional; +import javax.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.keygen.KeyGenerators; import org.springframework.security.crypto.password.PasswordEncoder; @@ -147,4 +148,23 @@ public class FlingService { log.debug("Generated share id {}", shareId); return shareId; } + + public FlingDto replace(UUID id, @Valid FlingDto flingDto) { + FlingEntity flingEntity = flingRepository.getOne(id); + flingEntity.setId(id); + flingEntity.setAllowUpload(flingDto.getAllowUpload()); + flingEntity.setDirectDownload(flingDto.getDirectDownload()); + flingEntity.setExpirationClicks(flingDto.getExpirationClicks()); + flingEntity.setExpirationTime(flingDto.getExpirationTime()); + flingEntity.setName(flingDto.getName()); + flingEntity.setShareId(flingDto.getShareId()); + + if(!flingEntity.getAuthCode().equals(flingDto.getAuthCode()) + && !flingEntity.getAuthCode().equals(hashAuthCode(flingDto.getAuthCode()))) { + + flingEntity.setAuthCode(hashAuthCode(flingDto.getAuthCode())); + } + + return flingMapper.map(flingEntity); + } } diff --git a/service/fling/src/test/java/net/friedl/fling/controller/FlingControllerTest.java b/service/fling/src/test/java/net/friedl/fling/controller/FlingControllerTest.java index 1860f13..22c95c6 100644 --- a/service/fling/src/test/java/net/friedl/fling/controller/FlingControllerTest.java +++ b/service/fling/src/test/java/net/friedl/fling/controller/FlingControllerTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -108,6 +109,27 @@ public class FlingControllerTest { .andExpect(status().isBadRequest()); } + @Test + public void replaceFling_validatesBody_notOk() throws Exception { + FlingDto invalidFlingDto = new FlingDto(); + + mockMvc.perform(put("/api/fling/{id}", flingId) + .content(mapper.writeValueAsString(invalidFlingDto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + public void replaceFling_ok() throws Exception { + FlingDto flingDto = new FlingDto(flingId, "new-name", Instant.EPOCH, "shareId", "new-authCode", + false, true, true, 1, null); + + mockMvc.perform(put("/api/fling/{id}", flingId) + .content(mapper.writeValueAsString(flingDto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + @Test public void postArtifact_ok() throws Exception { mockMvc.perform(post("/api/fling/{id}/artifacts", flingId) diff --git a/service/fling/src/test/java/net/friedl/fling/service/FlingServiceTest.java b/service/fling/src/test/java/net/friedl/fling/service/FlingServiceTest.java index f16072b..37ecd84 100644 --- a/service/fling/src/test/java/net/friedl/fling/service/FlingServiceTest.java +++ b/service/fling/src/test/java/net/friedl/fling/service/FlingServiceTest.java @@ -11,6 +11,7 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; @@ -154,6 +155,40 @@ public class FlingServiceTest { FlingDto createdFling = flingService.create(flingDto); assertThat(createdFling.getShareId(), is("test")); } + + @Test + public void replace_newName_expirationClicks_setsNameAndExpirationClicks() { + FlingDto flingDto = FlingDto.builder() + .name("testName") + .authCode(flingEntity1.getAuthCode()) + .expirationClicks(2) + .build(); + + when(flingRepository.getOne(flingEntity1.getId())).thenReturn(flingEntity1); + FlingDto updatedEntity = flingService.replace(flingEntity1.getId(), flingDto); + + assertThat(updatedEntity.getId(), is(flingEntity1.getId())); + assertThat(updatedEntity.getName(), is("testName")); + assertThat(updatedEntity.getAuthCode(), is(flingEntity1.getAuthCode())); + assertThat(updatedEntity.getExpirationClicks(), is(2)); + } + + @Test + public void replace_newAuthCode_setsNewHashedAuthCode() { + FlingDto flingDto = FlingDto.builder() + .name(flingEntity1.getName()) + .authCode("new-secret") + .build(); + + when(passwordEncoder.encode("new-secret")).thenReturn("new-secret-hash"); + when(flingRepository.getOne(flingEntity1.getId())).thenReturn(flingEntity1); + FlingDto updatedEntity = flingService.replace(flingEntity1.getId(), flingDto); + + verify(passwordEncoder, atLeast(1)).encode("new-secret"); + assertThat(updatedEntity.getId(), is(flingEntity1.getId())); + assertThat(updatedEntity.getAuthCode(), is("new-secret-hash")); + assertThat(updatedEntity.getName(), is(flingEntity1.getName())); + } @Test public void getByShareId_flingDto() { diff --git a/web/fling/src/components/admin/Settings.jsx b/web/fling/src/components/admin/Settings.jsx index a516792..b150f3a 100644 --- a/web/fling/src/components/admin/Settings.jsx +++ b/web/fling/src/components/admin/Settings.jsx @@ -1,277 +1,231 @@ import log from 'loglevel'; -import React, {useState, useEffect} from 'react'; +import React, { useState } from 'react'; +import produce from 'immer'; +import _ from 'lodash'; -import {flingClient} from '../../util/flingclient'; +import { useSelector, useDispatch } from 'react-redux'; +import { Fling } from '@fling/flingclient'; + +import { retrieveFlings } from "../../redux/actions"; +import { FlingClient } from '../../util/fc'; export default function Settings(props) { - let defaultState = () => ({name: "", authCode: "", - sharing: {directDownload: false, allowUpload: true, shared: true, shareUrl: ""}, - expiration: {}}); + let flingClient = new FlingClient(); + let dispatch = useDispatch(); - let [fling, setFling] = useState(defaultState()); - let [shareUrlUnique, setShareUrlUnique] = useState(true); - let [authCodeChangeable, setAuthCodeChangeable] = useState(false); - let [reload, setReload] = useState(true); + /** + * The active fling from the redux store. Treat this as immutable. + */ + let activeFling = useSelector(state => state.flings.activeFling); + let _clone = _.cloneDeep(_.toPlainObject(_.cloneDeep(activeFling))); - useEffect(() => { - if(props.activeFling && reload) { - flingClient.getFling(props.activeFling) - .then(result => { - let f = {...fling, ...result}; - let s = {...fling.sharing, ...result.sharing}; - let e = {...fling.expiration, ...result.expiration}; + /** + * Deep clone the active fling from redux into a draft. Changes to the + * settings will be stored in the draft until saved and pushed to the + * backend. This in turn will synchronize back to the redux store. + * + * The draft, just as the activeFling, is of type Fling + */ + let [draft, setDraft] = useState({ fling: _clone }); - f.sharing = s; - f.expiration = e; - setFling(f); + let [shareUrlUnique, setShareUrlUnique] = useState(true); + let [authCodeChangeable, setAuthCodeChangeable] = useState(false); - setAuthCodeChangeable(!f.authCode); - setReload(false); - }); - } - }, [props.activeFling, reload, fling]); + /** + * Publishes the draft to the backend and refreshes the redux store + */ + function publishDraft() { + flingClient.putFling(draft).then( + success => { + log.info("Saved new settings {}", draft); + dispatch(retrieveFlings()); + }, + error => log.error("Could not save new settings for {}: {}", activeFling.id, error)); + } - function reloadSettings(ev) { - ev.preventDefault(); - setFling(defaultState()); - setReload(true); - } + /** + * Resets the draft to a new clone of the active fling. All draft + * modifications get lost. + */ + function resetDraft() { + setDraft(produce({}, draft => { return { fling: activeFling }; })); + } - function resetAuthCode(ev) { - if(fling.authCode) { - let f = {...fling}; - f.authCode = ""; - setFling(f); - } + /** + * A helper shim for persistent produce. + * + * Executes `fun` in immer.produce, hereby generating a new draft `newDraft`, + * and sets it into local state via `setDraft(newDraft)` + */ + let _pproduce = (fun) => (...args) => { + let x = produce(fun)(...args); + setDraft(x); + } - if(!ev.currentTarget.checked) { - setAuthCodeChangeable(true); - } - } - - function toggleSharing(ev) { - let f = {...fling}; - let s = {...fling.sharing}; - - if(ev.currentTarget.id === "direct-download") { - if(ev.currentTarget.checked) { - s.directDownload = true; - s.shared = true; - s.allowUpload = false; - } else { - s.directDownload = false; - } - } else if(ev.currentTarget.id === "allow-upload") { - if(ev.currentTarget.checked) { - s.allowUpload = true; - s.shared = true; - s.directDownload = false; - } else { - s.allowUpload = false; - } - } else if(ev.currentTarget.id === "shared") { - if(!ev.currentTarget.checked) { - s.allowUpload = s.directDownload = s.shared = false; - } else { - s.shared = true; - } - } - - f.sharing = s; - - setFling(f); - } - - function setShareUrl(ev) { - let f = {...fling}; - let s = {...fling.sharing}; //TODO: expiration is not cloned - let value = ev.currentTarget.value; - - if(!value) { - setShareUrlUnique(false); - s.shareUrl = value; - f.sharing = s; - setFling(f); - return; - } - - flingClient.getFlingByShareId(ev.currentTarget.value) - .then(result => { - if(!result) { - setShareUrlUnique(true); - } else if(props.activeFling === result.id) { // share url didn't change - setShareUrlUnique(true); - } else { - setShareUrlUnique(false); - } - - s.shareUrl = value; - f.sharing = s; - setFling(f); - }); - } - - function setName(ev) { - let f = {...fling}; - let value = ev.currentTarget.value; - - f.name = value; - setFling(f); - } - - function setExpirationType(ev) { - let f = {...fling}; - let e = {...fling.expiration}; //TODO: sharing is not cloned - let value = ev.currentTarget.value; - - if(value === "never") { - e = {}; + /** + * Sets the sharing toggles to valid combinations depending on the changed + * setting and its new value. + * + * Creates a new draft and sets it into the local state. + */ + let toggleSharing = _pproduce((newDraft, setting, enabled) => { + switch (setting) { + case "direct-download": + if (enabled) { + newDraft.fling.directDownload = true; + newDraft.fling.shared = true; + newDraft.fling.allowUpload = false; } else { - e.type = value; - e.value = ""; + newDraft.fling.directDownload = false; } + return newDraft.fling; + case "allow-upload": + if (enabled) { + newDraft.fling.allowUpload = true; + newDraft.fling.shared = true; + newDraft.fling.directDownload = false; + } else { + newDraft.fling.allowUpload = false; + } + return newDraft.fling; + case "shared": + if (enabled) { + newDraft.fling.allowUpload = false; + newDraft.fling.directDownload = false; + newDraft.fling.shared = false; + } else { + newDraft.fling.shared = true; + } + return newDraft; + default: + log.warn("Unknown action"); + break; + }; + }) + /** Sets the Fling.name. Creates a new draft and sets it into the local state. */ + let setName = produce((newDraft, name) => { newDraft.fling.name = name; return newDraft; }); + /** Sets the Fling.expirationTime. Creates a new draft and sets it into the local state. */ + let setExpirationTime = _pproduce((newDraft, time) => newDraft.fling.expirationTime = time); + /** Sets the Fling.expirationClicks. Creates a new draft and sets it into the local state. */ + let setExpirationClicks = _pproduce((newDraft, clicks) => newDraft.fling.clicks = clicks); + /** Sets the Fling.shareId. Creates a new draft and sets it into the local state. */ + let setShareId = _pproduce((newDraft, shareId) => newDraft.fling.shareId = shareId); + /** Sets the Fling.authCode. Creates a new draft and sets it into the local state. */ + let setAuthCode = _pproduce((newDraft, authCode) => newDraft.fling.authCode = authCode); - f.expiration = e; - setFling(f); - } + let resetAuthCode = _pproduce((newDraft) => newDraft.fling.authCode = activeFling.authCode); - function setExpirationValue(ev) { - let f = {...fling}; - let e = {...fling.expiration}; //TODO: sharing is not cloned - let value = e.type === "time" ? ev.currentTarget.valueAsNumber: ev.currentTarget.value; - - e.value = value; - - f.expiration = e; - setFling(f); - } - - function formatExpirationTime() { - if (!fling.expiration || !fling.expiration.value || fling.expiration.type !== "time") - return ""; - - - let date = new Date(fling.expiration.value); - let fmt = date.toISOString().split("T")[0]; - return fmt; - } - - function setAuthCode(ev) { - let f = {...fling}; - let value = ev.currentTarget.value; - - f.authCode = value; - setFling(f); - } - - function handleSubmit(ev) { - ev.preventDefault(); - log.info(fling); - flingClient.putFling(props.activeFling, fling); - } - - return( -
-
-
-
-
-
- -
-
- -
-
-
-
- -
-
- - -
-
- -
-
- -
-
-
- - - -
-
-
- -
-
- -
-
-
- -
- -
-
- Expire after - - Clicks -
-
- -
-
- Expire after - -
-
-
-
- -
-
- -
-
- - - - -
-
- -
- - -
- + return ( +
+
+
+
+
+
+ +
+
+ { + ev.preventDefault(); + let nd = produce(draft, newDraft => { + newDraft.fling.name = ev.target.value; + return newDraft; + }) + setDraft(nd); + }} /> +
-
+
+
+ +
+
+ setShareId(ev.target.value)} /> + +
+
+ +
+
+ +
+
+
+ setAuthCode(ev.target.value)} /> + + +
+
+
+ +
+
+ +
+
+
+ +
+ +
+
+ Expire after + + Clicks +
+
+ +
+
+ Expire after + +
+
+
+
+ +
+
+ +
+
+ + + + +
+
+ +
+ + +
+
- ); +
+
+ ); } diff --git a/web/fling/src/redux/actions.js b/web/fling/src/redux/actions.js index 0e59239..efb9e76 100644 --- a/web/fling/src/redux/actions.js +++ b/web/fling/src/redux/actions.js @@ -1,4 +1,5 @@ import log from 'loglevel'; +import { cloneDeep, toPlainObject } from 'lodash'; import { SET_FLINGS, SET_ACTIVE_FLING, ADD_FLING } from "./actionTypes"; import { FlingClient } from "../util/fc"; @@ -6,21 +7,21 @@ import { FlingClient } from "../util/fc"; function setFlingsAction(flings) { return { type: SET_FLINGS, - payload: flings + payload: _purify(flings) } } function addFlingAction(fling) { return { type: ADD_FLING, - payload: fling + payload: _purify(fling) } } function setActiveFlingAction(fling) { return { type: SET_ACTIVE_FLING, - payload: fling + payload: _purify(fling) } } @@ -55,6 +56,13 @@ function setActiveFling(id) { } } +function _purify(o) { + if(Array.isArray(o)) { + return o.map(e => toPlainObject(cloneDeep(e))); + } + return toPlainObject(cloneDeep(o)); +} + function retrieveFlings() { return (dispatch, getState) => { const { flings: { activeFling } } = getState();