├── hoaxify-backend ├── src │ ├── test │ │ ├── resources │ │ │ ├── test-txt.txt │ │ │ ├── profile.png │ │ │ ├── test-gif.gif │ │ │ ├── test-jpg.jpg │ │ │ └── test-png.png │ │ └── java │ │ │ └── com │ │ │ └── hoaxify │ │ │ └── hoaxify │ │ │ ├── TestUtil.java │ │ │ ├── UserRepositoryTest.java │ │ │ ├── TestPage.java │ │ │ ├── FileServiceTest.java │ │ │ ├── FileAttachmentRepositoryTest.java │ │ │ ├── StaticResourceTest.java │ │ │ ├── FileUploadControllerTest.java │ │ │ └── LoginControllerTest.java │ └── main │ │ ├── resources │ │ ├── ValidationMessages_tr.properties │ │ ├── ValidationMessages.properties │ │ └── application.yml │ │ └── java │ │ └── com │ │ └── hoaxify │ │ └── hoaxify │ │ ├── shared │ │ ├── GenericResponse.java │ │ ├── CurrentUser.java │ │ ├── ProfileImage.java │ │ ├── ProfileImageValidator.java │ │ └── ExceptionHandlerAdvice.java │ │ ├── file │ │ ├── FileAttachmentRepository.java │ │ ├── FileAttachmentVM.java │ │ ├── FileUploadController.java │ │ ├── FileAttachment.java │ │ └── FileService.java │ │ ├── user │ │ ├── UserRepository.java │ │ ├── vm │ │ │ ├── UserUpdateVM.java │ │ │ └── UserVM.java │ │ ├── LoginController.java │ │ ├── UniqueUsernameValidator.java │ │ ├── UniqueUsername.java │ │ ├── UserController.java │ │ ├── UserService.java │ │ └── User.java │ │ ├── error │ │ ├── NotFoundException.java │ │ ├── ApiError.java │ │ └── ErrorHandler.java │ │ ├── hoax │ │ ├── HoaxRepository.java │ │ ├── HoaxSecurityService.java │ │ ├── vm │ │ │ └── HoaxVM.java │ │ ├── Hoax.java │ │ ├── HoaxController.java │ │ └── HoaxService.java │ │ ├── configuration │ │ ├── AppConfiguration.java │ │ ├── BasicAuthenticationEntryPoint.java │ │ ├── AuthUserService.java │ │ ├── WebConfiguration.java │ │ └── SecurityConfiguration.java │ │ └── HoaxifyApplication.java ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ ├── maven-wrapper.properties │ │ └── MavenWrapperDownloader.java ├── .gitignore ├── pom.xml ├── mvnw.cmd └── mvnw ├── frontend ├── src │ ├── setupTests.js │ ├── assets │ │ ├── profile.png │ │ └── hoaxify-logo.png │ ├── components │ │ ├── Spinner.js │ │ ├── ButtonWithProgress.js │ │ ├── ProfileImageWithDefault.js │ │ ├── UserListItem.js │ │ ├── Input.js │ │ ├── UserListItem.spec.js │ │ ├── ProfileImageWithDefault.spec.js │ │ ├── Modal.js │ │ ├── UserList.js │ │ ├── Modal.spec.js │ │ ├── ProfileCard.js │ │ ├── HoaxView.js │ │ ├── TopBar.js │ │ ├── Input.spec.js │ │ ├── ProfileCard.spec.js │ │ ├── HoaxSubmit.js │ │ ├── HoaxView.spec.js │ │ ├── TopBar.spec.js │ │ ├── HoaxFeed.js │ │ └── UserList.spec.js │ ├── index.css │ ├── redux │ │ ├── authReducer.js │ │ ├── authActions.js │ │ └── configureStore.js │ ├── containers │ │ └── App.js │ ├── index.js │ ├── shared │ │ └── useClickTracker.js │ ├── pages │ │ ├── HomePage.js │ │ ├── HomePage.spec.js │ │ ├── LoginPage.js │ │ ├── UserSignupPage.js │ │ ├── UserPage.js │ │ └── LoginPage.spec.js │ ├── api │ │ ├── apiCalls.js │ │ └── apiCalls.spec.js │ └── serviceWorker.js ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── .prettierrc ├── .gitignore ├── .vscode │ └── settings.json ├── package.json └── README.md └── README.md /hoaxify-backend/src/test/resources/test-txt.txt: -------------------------------------------------------------------------------- 1 | test file -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/assets/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/frontend/src/assets/profile.png -------------------------------------------------------------------------------- /frontend/src/assets/hoaxify-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/frontend/src/assets/hoaxify-logo.png -------------------------------------------------------------------------------- /hoaxify-backend/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/hoaxify-backend/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /hoaxify-backend/src/test/resources/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/hoaxify-backend/src/test/resources/profile.png -------------------------------------------------------------------------------- /hoaxify-backend/src/test/resources/test-gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/hoaxify-backend/src/test/resources/test-gif.gif -------------------------------------------------------------------------------- /hoaxify-backend/src/test/resources/test-jpg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/hoaxify-backend/src/test/resources/test-jpg.jpg -------------------------------------------------------------------------------- /hoaxify-backend/src/test/resources/test-png.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basarbk/tdd-spring-react/HEAD/hoaxify-backend/src/test/resources/test-png.png -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /hoaxify-backend/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.0/apache-maven-3.6.0-bin.zip 2 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/resources/ValidationMessages_tr.properties: -------------------------------------------------------------------------------- 1 | javax.validation.constraints.NotNull.message = Bos deger olamaz 2 | hoaxify.constraints.username.NotNull.message = Kullanici adi bos olamaz 3 | javax.validation.constraints.Size.message = En az {min} en fazla {max} karakter olmali -------------------------------------------------------------------------------- /frontend/src/components/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Spinner = () => { 4 | return ( 5 |
6 |
7 | Loading... 8 |
9 |
10 | ); 11 | }; 12 | 13 | export default Spinner; 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Test Driven Web Application Development with Spring & React 2 | 3 | This application is built with Spring and React and everything is implemented with TDD approach. 4 | You can find full training at Udemy, [Test Driven Web Application Development with Spring & React](https://www.udemy.com/course/test-driven-web-application-development-with-spring-react/?referralCode=5EE4FA2E84E78941F649) 5 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/shared/GenericResponse.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.shared; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | 6 | @Data 7 | @NoArgsConstructor 8 | public class GenericResponse { 9 | 10 | private String message; 11 | 12 | public GenericResponse(String message) { 13 | this.message = message; 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | yarn.lock -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/file/FileAttachmentRepository.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.file; 2 | 3 | import java.util.Date; 4 | import java.util.List; 5 | 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface FileAttachmentRepository extends JpaRepository{ 9 | 10 | List findByDateBeforeAndHoaxIsNull(Date date); 11 | 12 | } 13 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user; 2 | 3 | import org.springframework.data.domain.Page; 4 | import org.springframework.data.domain.Pageable; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | public interface UserRepository extends JpaRepository{ 8 | 9 | User findByUsername(String username); 10 | 11 | Page findByUsernameNot(String username, Pageable page); 12 | } 13 | -------------------------------------------------------------------------------- /hoaxify-backend/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/** 5 | !**/src/test/** 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | 30 | ### VS Code ### 31 | .vscode/ 32 | uploads-* 33 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/vm/UserUpdateVM.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user.vm; 2 | 3 | import javax.validation.constraints.NotNull; 4 | import javax.validation.constraints.Size; 5 | 6 | import com.hoaxify.hoaxify.shared.ProfileImage; 7 | 8 | import lombok.Data; 9 | 10 | @Data 11 | public class UserUpdateVM { 12 | 13 | @NotNull 14 | @Size(min=4, max=255) 15 | private String displayName; 16 | 17 | @ProfileImage 18 | private String image; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/file/FileAttachmentVM.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.file; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | 6 | @Data 7 | @NoArgsConstructor 8 | public class FileAttachmentVM { 9 | 10 | private String name; 11 | 12 | private String fileType; 13 | 14 | public FileAttachmentVM(FileAttachment fileAttachment) { 15 | this.setName(fileAttachment.getName()); 16 | this.setFileType(fileAttachment.getFileType()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/shared/CurrentUser.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.shared; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 9 | 10 | @Target(ElementType.PARAMETER) 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @AuthenticationPrincipal 13 | public @interface CurrentUser { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/error/NotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.error; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | public class NotFoundException extends RuntimeException{ 8 | 9 | /** 10 | * 11 | */ 12 | private static final long serialVersionUID = -4410642648720018834L; 13 | 14 | public NotFoundException(String message) { 15 | super(message); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/LoginController.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user; 2 | 3 | import org.springframework.web.bind.annotation.PostMapping; 4 | import org.springframework.web.bind.annotation.RestController; 5 | 6 | import com.hoaxify.hoaxify.shared.CurrentUser; 7 | import com.hoaxify.hoaxify.user.vm.UserVM; 8 | 9 | @RestController 10 | public class LoginController { 11 | 12 | @PostMapping("/api/1.0/login") 13 | UserVM handleLogin(@CurrentUser User loggedInUser) { 14 | return new UserVM(loggedInUser); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/resources/ValidationMessages.properties: -------------------------------------------------------------------------------- 1 | javax.validation.constraints.NotNull.message = Cannot be null 2 | hoaxify.constraints.username.NotNull.message = Username cannot be null 3 | javax.validation.constraints.Size.message = It must have minimum {min} and maximum {max} characters 4 | hoaxify.constraints.password.Pattern.message = Password must have at least one uppercase, one lowercase letter and one number 5 | hoaxify.constraints.username.UniqueUsername.message = This name is in use 6 | hoaxify.constraints.image.ProfileImage.message = Only PNG and JPG files are allowed -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/hoax/HoaxRepository.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.hoax; 2 | 3 | import org.springframework.data.domain.Page; 4 | import org.springframework.data.domain.Pageable; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 7 | 8 | import com.hoaxify.hoaxify.user.User; 9 | 10 | public interface HoaxRepository extends JpaRepository, JpaSpecificationExecutor{ 11 | 12 | Page findByUser(User user, Pageable pageable); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/components/ButtonWithProgress.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ButtonWithProgress = (props) => { 4 | return ( 5 | 17 | ); 18 | }; 19 | 20 | export default ButtonWithProgress; 21 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileImageWithDefault.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import defaultPicture from '../assets/profile.png'; 3 | 4 | const ProfileImageWithDefault = (props) => { 5 | let imageSource = defaultPicture; 6 | if (props.image) { 7 | imageSource = `/images/profile/${props.image}`; 8 | } 9 | return ( 10 | //eslint-disable-next-line 11 | { 15 | event.target.src = defaultPicture; 16 | }} 17 | /> 18 | ); 19 | }; 20 | 21 | export default ProfileImageWithDefault; 22 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/vm/UserVM.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user.vm; 2 | 3 | import com.hoaxify.hoaxify.user.User; 4 | 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @NoArgsConstructor 10 | public class UserVM { 11 | 12 | private long id; 13 | 14 | private String username; 15 | 16 | private String displayName; 17 | 18 | private String image; 19 | 20 | public UserVM(User user) { 21 | this.setId(user.getId()); 22 | this.setUsername(user.getUsername()); 23 | this.setDisplayName(user.getDisplayName()); 24 | this.setImage(user.getImage()); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/redux/authReducer.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | id: 0, 3 | username: '', 4 | displayName: '', 5 | image: '', 6 | password: '', 7 | isLoggedIn: false 8 | }; 9 | 10 | export default function authReducer(state = initialState, action) { 11 | if (action.type === 'logout-success') { 12 | return { ...initialState }; 13 | } else if (action.type === 'login-success') { 14 | return { 15 | ...action.payload, 16 | isLoggedIn: true 17 | }; 18 | } else if (action.type === 'update-success') { 19 | return { 20 | ...state, 21 | displayName: action.payload.displayName, 22 | image: action.payload.image 23 | }; 24 | } 25 | return state; 26 | } 27 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/UniqueUsernameValidator.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user; 2 | 3 | import javax.validation.ConstraintValidator; 4 | import javax.validation.ConstraintValidatorContext; 5 | 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | 8 | public class UniqueUsernameValidator implements ConstraintValidator{ 9 | 10 | @Autowired 11 | UserRepository userRepository; 12 | 13 | @Override 14 | public boolean isValid(String value, ConstraintValidatorContext context) { 15 | 16 | User inDB = userRepository.findByUsername(value); 17 | if(inDB == null) { 18 | return true; 19 | } 20 | 21 | return false; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/shared/ProfileImage.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.shared; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import javax.validation.Constraint; 9 | import javax.validation.Payload; 10 | 11 | @Constraint(validatedBy = ProfileImageValidator.class) 12 | @Target(ElementType.FIELD) 13 | @Retention(RetentionPolicy.RUNTIME) 14 | public @interface ProfileImage { 15 | 16 | String message() default "{hoaxify.constraints.image.ProfileImage.message}"; 17 | 18 | Class[] groups() default { }; 19 | 20 | Class[] payload() default { }; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/UserListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import ProfileImageWithDefault from './ProfileImageWithDefault'; 4 | 5 | const UserListItem = (props) => { 6 | return ( 7 | 11 | 18 | {`${props.user.displayName}@${ 19 | props.user.username 20 | }`} 21 | 22 | ); 23 | }; 24 | 25 | export default UserListItem; 26 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/file/FileUploadController.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.file; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.web.bind.annotation.PostMapping; 5 | import org.springframework.web.bind.annotation.RequestMapping; 6 | import org.springframework.web.bind.annotation.RestController; 7 | import org.springframework.web.multipart.MultipartFile; 8 | 9 | @RestController 10 | @RequestMapping("/api/1.0") 11 | public class FileUploadController { 12 | 13 | @Autowired 14 | FileService fileService; 15 | 16 | @PostMapping("/hoaxes/upload") 17 | FileAttachment uploadForHoax(MultipartFile file) { 18 | return fileService.saveAttachment(file); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/UniqueUsername.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import javax.validation.Constraint; 9 | import javax.validation.Payload; 10 | 11 | @Constraint(validatedBy = UniqueUsernameValidator.class) 12 | @Target(ElementType.FIELD) 13 | @Retention(RetentionPolicy.RUNTIME) 14 | public @interface UniqueUsername { 15 | 16 | String message() default "{hoaxify.constraints.username.UniqueUsername.message}"; 17 | 18 | Class[] groups() default { }; 19 | 20 | Class[] payload() default { }; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/file/FileAttachment.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.file; 2 | 3 | import java.util.Date; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.GeneratedValue; 7 | import javax.persistence.Id; 8 | import javax.persistence.OneToOne; 9 | import javax.persistence.Temporal; 10 | import javax.persistence.TemporalType; 11 | 12 | import com.hoaxify.hoaxify.hoax.Hoax; 13 | 14 | import lombok.Data; 15 | 16 | @Data 17 | @Entity 18 | public class FileAttachment { 19 | 20 | @Id 21 | @GeneratedValue 22 | private long id; 23 | 24 | @Temporal(TemporalType.TIMESTAMP) 25 | private Date date; 26 | 27 | private String name; 28 | 29 | private String fileType; 30 | 31 | @OneToOne 32 | private Hoax hoax; 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | import HomePage from '../pages/HomePage'; 4 | import LoginPage from '../pages/LoginPage'; 5 | import UserSignupPage from '../pages/UserSignupPage'; 6 | import UserPage from '../pages/UserPage'; 7 | import TopBar from '../components/TopBar'; 8 | 9 | function App() { 10 | return ( 11 |
12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | ); 23 | } 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import { HashRouter } from 'react-router-dom'; 5 | import * as serviceWorker from './serviceWorker'; 6 | import App from './containers/App'; 7 | import { Provider } from 'react-redux'; 8 | import configureStore from './redux/configureStore'; 9 | 10 | const store = configureStore(); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById('root') 19 | ); 20 | 21 | // If you want your app to work offline and load faster, you can change 22 | // unregister() to register() below. Note this comes with some pitfalls. 23 | // Learn more about service workers: https://bit.ly/CRA-PWA 24 | serviceWorker.unregister(); 25 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/configuration/AppConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.configuration; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.context.annotation.Configuration; 5 | 6 | import lombok.Data; 7 | 8 | @Configuration 9 | @ConfigurationProperties(prefix = "hoaxify") 10 | @Data 11 | public class AppConfiguration { 12 | 13 | String uploadPath; 14 | 15 | String profileImagesFolder = "profile"; 16 | 17 | String attachmentsFolder = "attachments"; 18 | 19 | public String getFullProfileImagesPath() { 20 | return this.uploadPath + "/" + this.profileImagesFolder; 21 | } 22 | 23 | public String getFullAttachmentsPath() { 24 | return this.uploadPath + "/" + this.attachmentsFolder; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/redux/authActions.js: -------------------------------------------------------------------------------- 1 | import * as apiCalls from '../api/apiCalls'; 2 | 3 | export const loginSuccess = (loginUserData) => { 4 | return { 5 | type: 'login-success', 6 | payload: loginUserData 7 | }; 8 | }; 9 | 10 | export const loginHandler = (credentials) => { 11 | return function(dispatch) { 12 | return apiCalls.login(credentials).then((response) => { 13 | dispatch( 14 | loginSuccess({ 15 | ...response.data, 16 | password: credentials.password 17 | }) 18 | ); 19 | return response; 20 | }); 21 | }; 22 | }; 23 | 24 | export const signupHandler = (user) => { 25 | return function(dispatch) { 26 | return apiCalls.signup(user).then((response) => { 27 | return dispatch(loginHandler(user)); 28 | }); 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /hoaxify-backend/src/test/java/com/hoaxify/hoaxify/TestUtil.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify; 2 | 3 | import com.hoaxify.hoaxify.hoax.Hoax; 4 | import com.hoaxify.hoaxify.user.User; 5 | 6 | public class TestUtil { 7 | 8 | public static User createValidUser() { 9 | User user = new User(); 10 | user.setUsername("test-user"); 11 | user.setDisplayName("test-display"); 12 | user.setPassword("P4ssword"); 13 | user.setImage("profile-image.png"); 14 | return user; 15 | } 16 | 17 | public static User createValidUser(String username) { 18 | User user = createValidUser(); 19 | user.setUsername(username); 20 | return user; 21 | } 22 | 23 | public static Hoax createValidHoax() { 24 | Hoax hoax = new Hoax(); 25 | hoax.setContent("test content for the test hoax"); 26 | return hoax; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/hoax/HoaxSecurityService.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.hoax; 2 | 3 | import java.util.Optional; 4 | 5 | import org.springframework.stereotype.Service; 6 | 7 | import com.hoaxify.hoaxify.user.User; 8 | 9 | @Service 10 | public class HoaxSecurityService { 11 | 12 | HoaxRepository hoaxRepository; 13 | 14 | public HoaxSecurityService(HoaxRepository hoaxRepository) { 15 | super(); 16 | this.hoaxRepository = hoaxRepository; 17 | } 18 | 19 | public boolean isAllowedToDelete(long hoaxId, User loggedInUser) { 20 | Optional optionalHoax = hoaxRepository.findById(hoaxId); 21 | if(optionalHoax.isPresent()) { 22 | Hoax inDB = optionalHoax.get(); 23 | return inDB.getUser().getId() == loggedInUser.getId(); 24 | } 25 | return false; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/error/ApiError.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.error; 2 | 3 | import java.util.Date; 4 | import java.util.Map; 5 | 6 | import com.fasterxml.jackson.annotation.JsonInclude; 7 | import com.fasterxml.jackson.annotation.JsonInclude.Include; 8 | 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @JsonInclude(value = Include.NON_NULL) 15 | public class ApiError { 16 | 17 | private long timestamp = new Date().getTime(); 18 | 19 | private int status; 20 | 21 | private String message; 22 | 23 | private String url; 24 | 25 | private Map validationErrors; 26 | 27 | public ApiError(int status, String message, String url) { 28 | super(); 29 | this.status = status; 30 | this.message = message; 31 | this.url = url; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: active: 3 | - dev 4 | h2: 5 | console: 6 | enabled: true 7 | path: /h2-console jpa: properties: javax: 8 | persistence: 9 | validation: 10 | mode: none data: web: pageable: default-page-size: 10 11 | max-page-size: 100 12 | --- 13 | spring: 14 | profiles: prod 15 | datasource: url: jdbc:h2:./hoaxify-prod 16 | username: sa 17 | jpa: hibernate: ddl-auto: update 18 | h2: 19 | console: enabled: false 20 | hoaxify: 21 | upload-path: uploads-prod 22 | --- 23 | spring: 24 | profiles: dev 25 | datasource: url: jdbc:h2:mem:hoaxify-dev 26 | hoaxify: 27 | upload-path: uploads-dev 28 | --- 29 | spring: 30 | profiles: test 31 | hoaxify: 32 | upload-path: uploads-test 33 | -------------------------------------------------------------------------------- /frontend/src/shared/useClickTracker.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const useClickTracker = (actionArea) => { 4 | const [dropDownVisible, setDropDownVisible] = useState(false); 5 | 6 | useEffect(() => { 7 | const onClickTracker = (event) => { 8 | if (!actionArea.current) { 9 | setDropDownVisible(false); 10 | return; 11 | } 12 | if (dropDownVisible) { 13 | setDropDownVisible(false); 14 | } else if (actionArea.current.contains(event.target)) { 15 | setDropDownVisible(true); 16 | } 17 | }; 18 | document.addEventListener('click', onClickTracker); 19 | return function cleanup() { 20 | document.removeEventListener('click', onClickTracker); 21 | }; 22 | }, [actionArea, dropDownVisible]); 23 | 24 | return dropDownVisible; 25 | }; 26 | 27 | export default useClickTracker; 28 | -------------------------------------------------------------------------------- /frontend/src/pages/HomePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import UserList from '../components/UserList'; 3 | import HoaxSubmit from '../components/HoaxSubmit'; 4 | import { connect } from 'react-redux'; 5 | import HoaxFeed from '../components/HoaxFeed'; 6 | 7 | class HomePage extends React.Component { 8 | render() { 9 | return ( 10 |
11 |
12 |
13 | {this.props.loggedInUser.isLoggedIn && } 14 | 15 |
16 |
17 | 18 |
19 |
20 |
21 | ); 22 | } 23 | } 24 | const mapStateToProps = (state) => { 25 | return { 26 | loggedInUser: state 27 | }; 28 | }; 29 | 30 | export default connect(mapStateToProps)(HomePage); 31 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/configuration/BasicAuthenticationEntryPoint.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.configuration; 2 | 3 | import java.io.IOException; 4 | 5 | import javax.servlet.ServletException; 6 | import javax.servlet.http.HttpServletRequest; 7 | import javax.servlet.http.HttpServletResponse; 8 | 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.security.core.AuthenticationException; 11 | import org.springframework.security.web.AuthenticationEntryPoint; 12 | 13 | public class BasicAuthenticationEntryPoint implements AuthenticationEntryPoint{ 14 | 15 | @Override 16 | public void commence(HttpServletRequest request, HttpServletResponse response, 17 | AuthenticationException authException) throws IOException, ServletException { 18 | 19 | response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); 20 | 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/hoax/vm/HoaxVM.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.hoax.vm; 2 | 3 | import com.hoaxify.hoaxify.file.FileAttachmentVM; 4 | import com.hoaxify.hoaxify.hoax.Hoax; 5 | import com.hoaxify.hoaxify.user.vm.UserVM; 6 | 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | public class HoaxVM { 13 | 14 | private long id; 15 | 16 | private String content; 17 | 18 | private long date; 19 | 20 | private UserVM user; 21 | 22 | private FileAttachmentVM attachment; 23 | 24 | public HoaxVM(Hoax hoax) { 25 | this.setId(hoax.getId()); 26 | this.setContent(hoax.getContent()); 27 | this.setDate(hoax.getTimestamp().getTime()); 28 | this.setUser(new UserVM(hoax.getUser())); 29 | if(hoax.getAttachment() != null) { 30 | this.setAttachment(new FileAttachmentVM(hoax.getAttachment())); 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/components/Input.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Input = (props) => { 4 | let inputClassName = 'form-control'; 5 | 6 | if (props.type === 'file') { 7 | inputClassName += '-file'; 8 | } 9 | if (props.hasError !== undefined) { 10 | inputClassName += props.hasError ? ' is-invalid' : ' is-valid'; 11 | } 12 | 13 | return ( 14 |
15 | {props.label && } 16 | 24 | {props.hasError && ( 25 | {props.error} 26 | )} 27 |
28 | ); 29 | }; 30 | 31 | Input.defaultProps = { 32 | onChange: () => {} 33 | }; 34 | 35 | export default Input; 36 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/shared/ProfileImageValidator.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.shared; 2 | 3 | import java.util.Base64; 4 | 5 | import javax.validation.ConstraintValidator; 6 | import javax.validation.ConstraintValidatorContext; 7 | 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | 10 | import com.hoaxify.hoaxify.file.FileService; 11 | 12 | public class ProfileImageValidator implements ConstraintValidator{ 13 | 14 | @Autowired 15 | FileService fileService; 16 | 17 | @Override 18 | public boolean isValid(String value, ConstraintValidatorContext context) { 19 | if(value == null) { 20 | return true; 21 | } 22 | 23 | byte[] decodedBytes = Base64.getDecoder().decode(value); 24 | String fileType = fileService.detectType(decodedBytes); 25 | if(fileType.equalsIgnoreCase("image/png") || fileType.equalsIgnoreCase("image/jpeg")) { 26 | return true; 27 | } 28 | 29 | return false; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/configuration/AuthUserService.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.configuration; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.security.core.userdetails.UserDetails; 5 | import org.springframework.security.core.userdetails.UserDetailsService; 6 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 7 | import org.springframework.stereotype.Service; 8 | 9 | import com.hoaxify.hoaxify.user.User; 10 | import com.hoaxify.hoaxify.user.UserRepository; 11 | 12 | @Service 13 | public class AuthUserService implements UserDetailsService { 14 | 15 | @Autowired 16 | UserRepository userRepository; 17 | 18 | @Override 19 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 20 | User user = userRepository.findByUsername(username); 21 | if(user == null) { 22 | throw new UsernameNotFoundException("User not found"); 23 | } 24 | return user; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /frontend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.decorations.enabled": false, 3 | "scm.diffDecorations": "none", 4 | "files.exclude": { 5 | ".vscode": true, 6 | "node_modules": true, 7 | "package-lock.json": true, 8 | "yarn.lock": true, 9 | "README.md": true, 10 | ".prettierrc": true 11 | }, 12 | "editor.formatOnType": true, 13 | "breadcrumbs.filePath": "off", 14 | "breadcrumbs.symbolPath": "off", 15 | "breadcrumbs.enabled": false, 16 | "workbench.statusBar.visible": false, 17 | "editor.wordBasedSuggestions": false, 18 | "javascript.suggest.enabled": false, 19 | "javascript.suggest.autoImports": false, 20 | "editor.suggest.snippetsPreventQuickSuggestions": false, 21 | "editor.suggest.filterGraceful": false, 22 | "editor.lineNumbers": "off", 23 | "problems.decorations.enabled": false, 24 | "workbench.activityBar.visible": false, 25 | "editor.formatOnPaste": true, 26 | "editor.formatOnSave": true, 27 | "zenMode.centerLayout": false, 28 | "editor.find.addExtraSpaceOnTop": false 29 | } 30 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/hoax/Hoax.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.hoax; 2 | 3 | import java.util.Date; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.Entity; 7 | import javax.persistence.GeneratedValue; 8 | import javax.persistence.Id; 9 | import javax.persistence.ManyToOne; 10 | import javax.persistence.OneToOne; 11 | import javax.persistence.Temporal; 12 | import javax.persistence.TemporalType; 13 | import javax.validation.constraints.NotNull; 14 | import javax.validation.constraints.Size; 15 | 16 | import com.hoaxify.hoaxify.file.FileAttachment; 17 | import com.hoaxify.hoaxify.user.User; 18 | 19 | import lombok.Data; 20 | 21 | @Data 22 | @Entity 23 | public class Hoax { 24 | 25 | @Id 26 | @GeneratedValue 27 | private long id; 28 | 29 | @NotNull 30 | @Size(min = 10, max=5000) 31 | @Column(length = 5000) 32 | private String content; 33 | 34 | @Temporal(TemporalType.TIMESTAMP) 35 | private Date timestamp; 36 | 37 | @ManyToOne 38 | private User user; 39 | 40 | @OneToOne(mappedBy="hoax", orphanRemoval = true) 41 | private FileAttachment attachment; 42 | } 43 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/HoaxifyApplication.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify; 2 | 3 | import java.util.stream.IntStream; 4 | 5 | import org.springframework.boot.CommandLineRunner; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Profile; 10 | 11 | import com.hoaxify.hoaxify.user.User; 12 | import com.hoaxify.hoaxify.user.UserService; 13 | 14 | @SpringBootApplication 15 | public class HoaxifyApplication { 16 | 17 | public static void main(String[] args) { 18 | SpringApplication.run(HoaxifyApplication.class, args); 19 | } 20 | 21 | @Bean 22 | @Profile("dev") 23 | CommandLineRunner run(UserService userService) { 24 | return (args) -> { 25 | IntStream.rangeClosed(1,15) 26 | .mapToObj(i -> { 27 | User user = new User(); 28 | user.setUsername("user"+i); 29 | user.setDisplayName("display"+i); 30 | user.setPassword("P4ssword"); 31 | return user; 32 | }) 33 | .forEach(userService::save); 34 | 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.21.0", 7 | "react": "^17.0.1", 8 | "react-dom": "^17.0.1", 9 | "react-redux": "^7.2.2", 10 | "react-router-dom": "^5.2.0", 11 | "react-scripts": "^4.0.1", 12 | "redux": "^4.0.5", 13 | "redux-thunk": "^2.3.0", 14 | "timeago.js": "^4.0.2" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | }, 37 | "devDependencies": { 38 | "@testing-library/jest-dom": "^5.11.6", 39 | "@testing-library/react": "^11.2.2", 40 | "redux-logger": "^3.0.6" 41 | }, 42 | "proxy": "http://localhost:8080", 43 | "jest": { 44 | "resetMocks": false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/redux/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import authReducer from './authReducer'; 3 | import logger from 'redux-logger'; 4 | import thunk from 'redux-thunk'; 5 | import * as apiCalls from '../api/apiCalls'; 6 | 7 | const configureStore = (addLogger = true) => { 8 | let localStorageData = localStorage.getItem('hoax-auth'); 9 | 10 | let persistedState = { 11 | id: 0, 12 | username: '', 13 | displayName: '', 14 | image: '', 15 | password: '', 16 | isLoggedIn: false 17 | }; 18 | if (localStorageData) { 19 | try { 20 | persistedState = JSON.parse(localStorageData); 21 | apiCalls.setAuthorizationHeader(persistedState); 22 | } catch (error) {} 23 | } 24 | 25 | const middleware = addLogger 26 | ? applyMiddleware(thunk, logger) 27 | : applyMiddleware(thunk); 28 | const store = createStore(authReducer, persistedState, middleware); 29 | 30 | store.subscribe(() => { 31 | localStorage.setItem('hoax-auth', JSON.stringify(store.getState())); 32 | apiCalls.setAuthorizationHeader(store.getState()); 33 | }); 34 | 35 | return store; 36 | }; 37 | 38 | export default configureStore; 39 | -------------------------------------------------------------------------------- /hoaxify-backend/src/test/java/com/hoaxify/hoaxify/UserRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 8 | import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; 9 | import org.springframework.test.context.ActiveProfiles; 10 | 11 | import com.hoaxify.hoaxify.user.User; 12 | import com.hoaxify.hoaxify.user.UserRepository; 13 | 14 | @DataJpaTest 15 | @ActiveProfiles("test") 16 | public class UserRepositoryTest { 17 | 18 | @Autowired 19 | TestEntityManager testEntityManager; 20 | 21 | @Autowired 22 | UserRepository userRepository; 23 | 24 | @Test 25 | public void findByUsername_whenUserExists_returnsUser() { 26 | testEntityManager.persist(TestUtil.createValidUser()); 27 | 28 | User inDB = userRepository.findByUsername("test-user"); 29 | assertThat(inDB).isNotNull(); 30 | 31 | } 32 | 33 | @Test 34 | public void findByUsername_whenUserDoesNotExist_returnsNull() { 35 | User inDB = userRepository.findByUsername("nonexistinguser"); 36 | assertThat(inDB).isNull(); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/UserListItem.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import UserListItem from './UserListItem'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | 6 | const user = { 7 | username: 'user1', 8 | displayName: 'display1', 9 | image: 'profile1.png' 10 | }; 11 | 12 | const setup = (propUser = user) => { 13 | return render( 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | describe('UserListItem', () => { 21 | it('has image', () => { 22 | const { container } = setup(); 23 | const image = container.querySelector('img'); 24 | expect(image).toBeInTheDocument(); 25 | }); 26 | it('displays default image when user does not have one', () => { 27 | const userWithoutImage = { 28 | ...user, 29 | image: undefined 30 | }; 31 | const { container } = setup(userWithoutImage); 32 | const image = container.querySelector('img'); 33 | expect(image.src).toContain('/profile.png'); 34 | }); 35 | it('displays users image when user have one', () => { 36 | const { container } = setup(); 37 | const image = container.querySelector('img'); 38 | expect(image.src).toContain('/images/profile/' + user.image); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/error/ErrorHandler.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.error; 2 | 3 | import java.util.Map; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.web.error.ErrorAttributeOptions; 7 | import org.springframework.boot.web.error.ErrorAttributeOptions.Include; 8 | import org.springframework.boot.web.servlet.error.ErrorAttributes; 9 | import org.springframework.boot.web.servlet.error.ErrorController; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import org.springframework.web.context.request.WebRequest; 13 | 14 | @RestController 15 | public class ErrorHandler implements ErrorController { 16 | 17 | @Autowired 18 | private ErrorAttributes errorAttributes; 19 | 20 | @RequestMapping("/error") 21 | ApiError handleError(WebRequest webRequest) { 22 | Map attributes = errorAttributes.getErrorAttributes(webRequest, ErrorAttributeOptions.of(Include.MESSAGE)); 23 | 24 | String message = (String) attributes.get("message"); 25 | String url = (String) attributes.get("path"); 26 | int status = (Integer) attributes.get("status"); 27 | return new ApiError(status, message, url); 28 | } 29 | 30 | @Override 31 | public String getErrorPath() { 32 | return "/error"; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/shared/ExceptionHandlerAdvice.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.shared; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | import javax.servlet.http.HttpServletRequest; 7 | 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.validation.BindingResult; 10 | import org.springframework.validation.FieldError; 11 | import org.springframework.web.bind.MethodArgumentNotValidException; 12 | import org.springframework.web.bind.annotation.ExceptionHandler; 13 | import org.springframework.web.bind.annotation.ResponseStatus; 14 | import org.springframework.web.bind.annotation.RestControllerAdvice; 15 | 16 | import com.hoaxify.hoaxify.error.ApiError; 17 | 18 | @RestControllerAdvice 19 | public class ExceptionHandlerAdvice { 20 | 21 | 22 | @ExceptionHandler({MethodArgumentNotValidException.class}) 23 | @ResponseStatus(HttpStatus.BAD_REQUEST) 24 | ApiError handleValidationException(MethodArgumentNotValidException exception, HttpServletRequest request) { 25 | ApiError apiError = new ApiError(400, "Validation error", request.getServletPath()); 26 | 27 | BindingResult result = exception.getBindingResult(); 28 | 29 | Map validationErrors = new HashMap<>(); 30 | 31 | for(FieldError fieldError: result.getFieldErrors()) { 32 | validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); 33 | } 34 | apiError.setValidationErrors(validationErrors); 35 | 36 | return apiError; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/configuration/WebConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.configuration; 2 | 3 | import java.io.File; 4 | import java.util.concurrent.TimeUnit; 5 | 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.CommandLineRunner; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | import org.springframework.http.CacheControl; 11 | import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; 12 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 13 | 14 | @Configuration 15 | public class WebConfiguration implements WebMvcConfigurer{ 16 | 17 | @Autowired 18 | AppConfiguration appConfiguration; 19 | 20 | @Override 21 | public void addResourceHandlers(ResourceHandlerRegistry registry) { 22 | registry.addResourceHandler("/images/**") 23 | .addResourceLocations("file:" + appConfiguration.getUploadPath() + "/") 24 | .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS)); 25 | } 26 | 27 | @Bean 28 | CommandLineRunner createUploadFolder() { 29 | return (args) -> { 30 | 31 | createNonExistingFolder(appConfiguration.getUploadPath()); 32 | createNonExistingFolder(appConfiguration.getFullProfileImagesPath()); 33 | createNonExistingFolder(appConfiguration.getFullAttachmentsPath()); 34 | }; 35 | } 36 | 37 | private void createNonExistingFolder(String path) { 38 | File folder = new File(path); 39 | boolean folderExist = folder.exists() && folder.isDirectory(); 40 | if(!folderExist) { 41 | folder.mkdir(); 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /hoaxify-backend/src/test/java/com/hoaxify/hoaxify/TestPage.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify; 2 | 3 | import java.util.Iterator; 4 | import java.util.List; 5 | import java.util.function.Function; 6 | 7 | import org.springframework.data.domain.Page; 8 | import org.springframework.data.domain.Pageable; 9 | import org.springframework.data.domain.Sort; 10 | 11 | import lombok.Data; 12 | 13 | @Data 14 | public class TestPage implements Page { 15 | 16 | long totalElements; 17 | int totalPages; 18 | int number; 19 | int numberOfElements; 20 | int size; 21 | boolean last; 22 | boolean first; 23 | boolean next; 24 | boolean previous; 25 | 26 | List content; 27 | 28 | @Override 29 | public boolean hasContent() { 30 | // TODO Auto-generated method stub 31 | return false; 32 | } 33 | 34 | @Override 35 | public Sort getSort() { 36 | // TODO Auto-generated method stub 37 | return null; 38 | } 39 | 40 | @Override 41 | public boolean hasNext() { 42 | return next; 43 | } 44 | 45 | @Override 46 | public boolean hasPrevious() { 47 | return previous; 48 | } 49 | 50 | @Override 51 | public Pageable nextPageable() { 52 | // TODO Auto-generated method stub 53 | return null; 54 | } 55 | 56 | @Override 57 | public Pageable previousPageable() { 58 | // TODO Auto-generated method stub 59 | return null; 60 | } 61 | 62 | @Override 63 | public Iterator iterator() { 64 | // TODO Auto-generated method stub 65 | return null; 66 | } 67 | 68 | @Override 69 | public Page map(Function converter) { 70 | // TODO Auto-generated method stub 71 | return null; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileImageWithDefault.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import ProfileImageWithDefault from './ProfileImageWithDefault'; 4 | 5 | describe('ProfileImageWithDefault', () => { 6 | describe('Layout', () => { 7 | it('has image', () => { 8 | const { container } = render(); 9 | const image = container.querySelector('img'); 10 | expect(image).toBeInTheDocument(); 11 | }); 12 | it('displays default image when image property not provided', () => { 13 | const { container } = render(); 14 | const image = container.querySelector('img'); 15 | expect(image.src).toContain('/profile.png'); 16 | }); 17 | it('displays user image when image property provided', () => { 18 | const { container } = render( 19 | 20 | ); 21 | const image = container.querySelector('img'); 22 | expect(image.src).toContain('/images/profile/profile1.png'); 23 | }); 24 | it('displays default image when provided image loading fails', () => { 25 | const { container } = render( 26 | 27 | ); 28 | const image = container.querySelector('img'); 29 | fireEvent.error(image); 30 | expect(image.src).toContain('/profile.png'); 31 | }); 32 | it('displays the image provided through src property', () => { 33 | const { container } = render( 34 | 35 | ); 36 | const image = container.querySelector('img'); 37 | expect(image.src).toContain('/image-from-src.png'); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /frontend/src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ButtonWithProgress from './ButtonWithProgress'; 3 | 4 | class Modal extends Component { 5 | render() { 6 | const { 7 | title, 8 | visible, 9 | body, 10 | okButton, 11 | cancelButton, 12 | onClickOk, 13 | onClickCancel, 14 | pendingApiCall 15 | } = this.props; 16 | 17 | let rootClass = 'modal fade'; 18 | let rootStyle; 19 | if (visible) { 20 | rootClass += ' d-block show'; 21 | rootStyle = { backgroundColor: '#000000b0' }; 22 | } 23 | return ( 24 |
25 |
26 |
27 |
28 |
{title}
29 |
30 |
{body}
31 |
32 | 39 | 46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | } 53 | 54 | Modal.defaultProps = { 55 | okButton: 'Ok', 56 | cancelButton: 'Cancel' 57 | }; 58 | 59 | export default Modal; 60 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/UserController.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user; 2 | 3 | import javax.validation.Valid; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.security.access.prepost.PreAuthorize; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.PostMapping; 12 | import org.springframework.web.bind.annotation.PutMapping; 13 | import org.springframework.web.bind.annotation.RequestBody; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | import com.hoaxify.hoaxify.shared.CurrentUser; 18 | import com.hoaxify.hoaxify.shared.GenericResponse; 19 | import com.hoaxify.hoaxify.user.vm.UserUpdateVM; 20 | import com.hoaxify.hoaxify.user.vm.UserVM; 21 | 22 | @RestController 23 | @RequestMapping("/api/1.0") 24 | public class UserController { 25 | 26 | @Autowired 27 | UserService userService; 28 | 29 | @PostMapping("/users") 30 | GenericResponse createUser(@Valid @RequestBody User user) { 31 | userService.save(user); 32 | return new GenericResponse("User saved"); 33 | } 34 | 35 | @GetMapping("/users") 36 | Page getUsers(@CurrentUser User loggedInUser, Pageable page) { 37 | return userService.getUsers(loggedInUser, page).map(UserVM::new); 38 | } 39 | 40 | @GetMapping("/users/{username}") 41 | UserVM getUserByName(@PathVariable String username) { 42 | User user = userService.getByUsername(username); 43 | return new UserVM(user); 44 | } 45 | 46 | @PutMapping("/users/{id:[0-9]+}") 47 | @PreAuthorize("#id == principal.id") 48 | UserVM updateUser(@PathVariable long id, @Valid @RequestBody(required = false) UserUpdateVM userUpdate) { 49 | User updated = userService.update(id, userUpdate); 50 | return new UserVM(updated); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/components/UserList.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import * as apiCalls from '../api/apiCalls'; 3 | import UserListItem from './UserListItem'; 4 | 5 | const UserList = (props) => { 6 | const [page, setPage] = useState({ 7 | content: [], 8 | number: 0, 9 | size: 3 10 | }); 11 | 12 | const [loadError, setLoadError] = useState(); 13 | 14 | useEffect(() => { 15 | loadData(); 16 | }, []); 17 | 18 | const loadData = (requestedPage = 0) => { 19 | apiCalls 20 | .listUsers({ page: requestedPage, size: 3 }) 21 | .then((response) => { 22 | setPage(response.data); 23 | setLoadError(); 24 | }) 25 | .catch((error) => { 26 | setLoadError('User load failed'); 27 | }); 28 | }; 29 | 30 | const onClickNext = () => { 31 | loadData(page.number + 1); 32 | }; 33 | 34 | const onClickPrevious = () => { 35 | loadData(page.number - 1); 36 | }; 37 | 38 | const { content, first, last } = page; 39 | 40 | return ( 41 |
42 |

Users

43 |
44 | {content.map((user) => { 45 | return ; 46 | })} 47 |
48 |
49 | {!first && ( 50 | {`< previous`} 55 | )} 56 | {!last && ( 57 | 62 | next > 63 | 64 | )} 65 |
66 | {loadError && ( 67 | {loadError} 68 | )} 69 |
70 | ); 71 | }; 72 | 73 | export default UserList; 74 | -------------------------------------------------------------------------------- /frontend/src/pages/HomePage.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import HomePage from './HomePage'; 4 | import * as apiCalls from '../api/apiCalls'; 5 | import { Provider } from 'react-redux'; 6 | import { createStore } from 'redux'; 7 | import authReducer from '../redux/authReducer'; 8 | 9 | const defaultState = { 10 | id: 1, 11 | username: 'user1', 12 | displayName: 'display1', 13 | image: 'profile1.png', 14 | password: 'P4ssword', 15 | isLoggedIn: true 16 | }; 17 | 18 | let store; 19 | 20 | const setup = (state = defaultState) => { 21 | store = createStore(authReducer, state); 22 | return render( 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | apiCalls.listUsers = jest.fn().mockResolvedValue({ 30 | data: { 31 | content: [], 32 | number: 0, 33 | size: 3 34 | } 35 | }); 36 | apiCalls.loadHoaxes = jest.fn().mockResolvedValue({ 37 | data: { 38 | content: [], 39 | number: 0, 40 | size: 3 41 | } 42 | }); 43 | 44 | describe('HomePage', () => { 45 | describe('Layout', () => { 46 | it('has root page div', () => { 47 | const { queryByTestId } = setup(); 48 | const homePageDiv = queryByTestId('homepage'); 49 | expect(homePageDiv).toBeInTheDocument(); 50 | }); 51 | it('displays hoax submit when user logged in', () => { 52 | const { container } = setup(); 53 | const textArea = container.querySelector('textarea'); 54 | expect(textArea).toBeInTheDocument(); 55 | }); 56 | it('does not display hoax submit when user not logged in', () => { 57 | const notLoggedInState = { 58 | id: 0, 59 | username: '', 60 | displayName: '', 61 | password: '', 62 | image: '', 63 | isLoggedIn: false 64 | }; 65 | const { container } = setup(notLoggedInState); 66 | const textArea = container.querySelector('textarea'); 67 | expect(textArea).not.toBeInTheDocument(); 68 | }); 69 | }); 70 | }); 71 | 72 | console.error = () => {}; 73 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | 28 | 34 | React App 35 | 36 | 37 | 38 |
39 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/UserService.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user; 2 | 3 | import java.io.IOException; 4 | 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.Pageable; 7 | import org.springframework.security.crypto.password.PasswordEncoder; 8 | import org.springframework.stereotype.Service; 9 | 10 | import com.hoaxify.hoaxify.error.NotFoundException; 11 | import com.hoaxify.hoaxify.file.FileService; 12 | import com.hoaxify.hoaxify.user.vm.UserUpdateVM; 13 | 14 | @Service 15 | public class UserService { 16 | 17 | UserRepository userRepository; 18 | 19 | PasswordEncoder passwordEncoder; 20 | 21 | FileService fileService; 22 | 23 | public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, FileService fileService) { 24 | super(); 25 | this.userRepository = userRepository; 26 | this.passwordEncoder = passwordEncoder; 27 | this.fileService = fileService; 28 | } 29 | 30 | public User save(User user) { 31 | user.setPassword(passwordEncoder.encode(user.getPassword())); 32 | return userRepository.save(user); 33 | } 34 | 35 | public Page getUsers(User loggedInUser, Pageable pageable) { 36 | if(loggedInUser != null) { 37 | return userRepository.findByUsernameNot(loggedInUser.getUsername(), pageable); 38 | } 39 | return userRepository.findAll(pageable); 40 | } 41 | 42 | public User getByUsername(String username) { 43 | User inDB = userRepository.findByUsername(username); 44 | if(inDB == null) { 45 | throw new NotFoundException(username + " not found"); 46 | } 47 | return inDB; 48 | } 49 | 50 | public User update(long id, UserUpdateVM userUpdate) { 51 | User inDB = userRepository.getOne(id); 52 | inDB.setDisplayName(userUpdate.getDisplayName()); 53 | if(userUpdate.getImage() != null) { 54 | String savedImageName; 55 | try { 56 | savedImageName = fileService.saveProfileImage(userUpdate.getImage()); 57 | fileService.deleteProfileImage(inDB.getImage()); 58 | inDB.setImage(savedImageName); 59 | } catch (IOException e) { 60 | e.printStackTrace(); 61 | } 62 | } 63 | return userRepository.save(inDB); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/user/User.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.user; 2 | 3 | import java.beans.Transient; 4 | import java.util.Collection; 5 | import java.util.List; 6 | 7 | import javax.persistence.Entity; 8 | import javax.persistence.GeneratedValue; 9 | import javax.persistence.Id; 10 | import javax.persistence.OneToMany; 11 | import javax.validation.constraints.NotNull; 12 | import javax.validation.constraints.Pattern; 13 | import javax.validation.constraints.Size; 14 | 15 | import org.springframework.security.core.GrantedAuthority; 16 | import org.springframework.security.core.authority.AuthorityUtils; 17 | import org.springframework.security.core.userdetails.UserDetails; 18 | 19 | import com.hoaxify.hoaxify.hoax.Hoax; 20 | 21 | import lombok.Data; 22 | 23 | @Data 24 | @Entity 25 | public class User implements UserDetails{ 26 | 27 | /** 28 | * 29 | */ 30 | private static final long serialVersionUID = 4074374728582967483L; 31 | 32 | @Id 33 | @GeneratedValue 34 | private long id; 35 | 36 | @NotNull(message = "{hoaxify.constraints.username.NotNull.message}") 37 | @Size(min = 4, max=255) 38 | @UniqueUsername 39 | private String username; 40 | 41 | @NotNull 42 | @Size(min = 4, max=255) 43 | private String displayName; 44 | 45 | @NotNull 46 | @Size(min = 8, max=255) 47 | @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$", message="{hoaxify.constraints.password.Pattern.message}") 48 | private String password; 49 | 50 | private String image; 51 | 52 | @OneToMany(mappedBy = "user") 53 | private List hoaxes; 54 | 55 | @Override 56 | @Transient 57 | public Collection getAuthorities() { 58 | return AuthorityUtils.createAuthorityList("Role_USER"); 59 | } 60 | 61 | @Override 62 | @Transient 63 | public boolean isAccountNonExpired() { 64 | return true; 65 | } 66 | 67 | @Override 68 | @Transient 69 | public boolean isAccountNonLocked() { 70 | return true; 71 | } 72 | 73 | @Override 74 | @Transient 75 | public boolean isCredentialsNonExpired() { 76 | return true; 77 | } 78 | 79 | @Override 80 | @Transient 81 | public boolean isEnabled() { 82 | return true; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/components/Modal.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import Modal from './Modal'; 4 | 5 | describe('Modal', () => { 6 | describe('Layout', () => { 7 | it('will be visible when visible property set to true', () => { 8 | const { queryByTestId } = render(); 9 | const modalRootDiv = queryByTestId('modal-root'); 10 | expect(modalRootDiv).toHaveClass('modal fade d-block show'); 11 | expect(modalRootDiv).toHaveStyle(`background-color: #000000b0`); 12 | }); 13 | it('displays the title provided as prop', () => { 14 | const { queryByText } = render(); 15 | expect(queryByText('Test Title')).toBeInTheDocument(); 16 | }); 17 | it('displays the body provided as prop', () => { 18 | const { queryByText } = render(); 19 | expect(queryByText('Test Body')).toBeInTheDocument(); 20 | }); 21 | it('displays OK button text provided as prop', () => { 22 | const { queryByText } = render(); 23 | expect(queryByText('OK')).toBeInTheDocument(); 24 | }); 25 | it('displays Cancel button text provided as prop', () => { 26 | const { queryByText } = render(); 27 | expect(queryByText('Cancel')).toBeInTheDocument(); 28 | }); 29 | it('displays defaults for buttons when corresponding props not provided', () => { 30 | const { queryByText } = render(); 31 | expect(queryByText('Ok')).toBeInTheDocument(); 32 | expect(queryByText('Cancel')).toBeInTheDocument(); 33 | }); 34 | it('calls callback function provided as prop when clicking ok button', () => { 35 | const mockFn = jest.fn(); 36 | const { queryByText } = render(); 37 | fireEvent.click(queryByText('Ok')); 38 | expect(mockFn).toHaveBeenCalled(); 39 | }); 40 | it('calls callback function provided as prop when clicking cancel button', () => { 41 | const mockFn = jest.fn(); 42 | const { queryByText } = render(); 43 | fireEvent.click(queryByText('Cancel')); 44 | expect(mockFn).toHaveBeenCalled(); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/configuration/SecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.configuration; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.http.HttpMethod; 6 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 7 | import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; 8 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 9 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 10 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 11 | import org.springframework.security.config.http.SessionCreationPolicy; 12 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | 15 | @EnableWebSecurity 16 | @EnableGlobalMethodSecurity(prePostEnabled = true) 17 | public class SecurityConfiguration extends WebSecurityConfigurerAdapter{ 18 | 19 | @Autowired 20 | AuthUserService authUserService; 21 | 22 | @Override 23 | protected void configure(HttpSecurity http) throws Exception { 24 | http.csrf().disable(); 25 | 26 | http.headers().disable(); 27 | 28 | http.httpBasic().authenticationEntryPoint(new BasicAuthenticationEntryPoint()); 29 | 30 | http 31 | .authorizeRequests() 32 | .antMatchers(HttpMethod.POST, "/api/1.0/login").authenticated() 33 | .antMatchers(HttpMethod.PUT, "/api/1.0/users/{id:[0-9]+}").authenticated() 34 | .antMatchers(HttpMethod.POST, "/api/1.0/hoaxes/**").authenticated() 35 | .antMatchers(HttpMethod.DELETE, "/api/1.0/hoaxes/{id:[0-9]+}").authenticated() 36 | .and() 37 | .authorizeRequests().anyRequest().permitAll(); 38 | 39 | http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); 40 | } 41 | 42 | @Override 43 | protected void configure(AuthenticationManagerBuilder auth) throws Exception { 44 | auth.userDetailsService(authUserService).passwordEncoder(passwordEncoder()); 45 | } 46 | 47 | @Bean 48 | public PasswordEncoder passwordEncoder() { 49 | return new BCryptPasswordEncoder(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/api/apiCalls.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export const signup = (user) => { 4 | return axios.post('/api/1.0/users', user); 5 | }; 6 | 7 | export const login = (user) => { 8 | return axios.post('/api/1.0/login', {}, { auth: user }); 9 | }; 10 | 11 | export const setAuthorizationHeader = ({ username, password, isLoggedIn }) => { 12 | if (isLoggedIn) { 13 | axios.defaults.headers.common['Authorization'] = `Basic ${btoa( 14 | username + ':' + password 15 | )}`; 16 | } else { 17 | delete axios.defaults.headers.common['Authorization']; 18 | } 19 | }; 20 | 21 | export const listUsers = (param = { page: 0, size: 3 }) => { 22 | const path = `/api/1.0/users?page=${param.page || 0}&size=${param.size || 3}`; 23 | return axios.get(path); 24 | }; 25 | 26 | export const getUser = (username) => { 27 | return axios.get(`/api/1.0/users/${username}`); 28 | }; 29 | 30 | export const updateUser = (userId, body) => { 31 | return axios.put('/api/1.0/users/' + userId, body); 32 | }; 33 | 34 | export const postHoax = (hoax) => { 35 | return axios.post('/api/1.0/hoaxes', hoax); 36 | }; 37 | 38 | export const loadHoaxes = (username) => { 39 | const basePath = username 40 | ? `/api/1.0/users/${username}/hoaxes` 41 | : '/api/1.0/hoaxes'; 42 | return axios.get(basePath + '?page=0&size=5&sort=id,desc'); 43 | }; 44 | 45 | export const loadOldHoaxes = (hoaxId, username) => { 46 | const basePath = username 47 | ? `/api/1.0/users/${username}/hoaxes` 48 | : '/api/1.0/hoaxes'; 49 | const path = `${basePath}/${hoaxId}?direction=before&page=0&size=5&sort=id,desc`; 50 | return axios.get(path); 51 | }; 52 | 53 | export const loadNewHoaxes = (hoaxId, username) => { 54 | const basePath = username 55 | ? `/api/1.0/users/${username}/hoaxes` 56 | : '/api/1.0/hoaxes'; 57 | const path = `${basePath}/${hoaxId}?direction=after&sort=id,desc`; 58 | return axios.get(path); 59 | }; 60 | 61 | export const loadNewHoaxCount = (hoaxId, username) => { 62 | const basePath = username 63 | ? `/api/1.0/users/${username}/hoaxes` 64 | : '/api/1.0/hoaxes'; 65 | const path = `${basePath}/${hoaxId}?direction=after&count=true`; 66 | return axios.get(path); 67 | }; 68 | 69 | export const postHoaxFile = (file) => { 70 | return axios.post('/api/1.0/hoaxes/upload', file); 71 | }; 72 | 73 | export const deleteHoax = (hoaxId) => { 74 | return axios.delete('/api/1.0/hoaxes/' + hoaxId); 75 | }; 76 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ProfileImageWithDefault from './ProfileImageWithDefault'; 3 | import Input from './Input'; 4 | import ButtonWithProgress from './ButtonWithProgress'; 5 | 6 | const ProfileCard = (props) => { 7 | const { displayName, username, image } = props.user; 8 | 9 | const showEditButton = props.isEditable && !props.inEditMode; 10 | 11 | return ( 12 |
13 |
14 | 22 |
23 |
24 | {!props.inEditMode &&

{`${displayName}@${username}`}

} 25 | {props.inEditMode && ( 26 |
27 | 34 |
35 | 41 |
42 |
43 | )} 44 | {showEditButton && ( 45 | 51 | )} 52 | {props.inEditMode && ( 53 |
54 | 59 | Save 60 | 61 | } 62 | pendingApiCall={props.pendingUpdateCall} 63 | disabled={props.pendingUpdateCall} 64 | /> 65 | 72 |
73 | )} 74 |
75 |
76 | ); 77 | }; 78 | 79 | ProfileCard.defaultProps = { 80 | errors: {} 81 | }; 82 | 83 | export default ProfileCard; 84 | -------------------------------------------------------------------------------- /frontend/src/components/HoaxView.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import ProfileImageWithDefault from './ProfileImageWithDefault'; 3 | import { format } from 'timeago.js'; 4 | import { Link } from 'react-router-dom'; 5 | import { connect } from 'react-redux'; 6 | import useClickTracker from '../shared/useClickTracker'; 7 | 8 | const HoaxView = (props) => { 9 | const actionArea = useRef(); 10 | const dropDownVisible = useClickTracker(actionArea); 11 | const { hoax, onClickDelete } = props; 12 | const { user, date } = hoax; 13 | const { username, displayName, image } = user; 14 | const relativeDate = format(date); 15 | const attachmentImageVisible = 16 | hoax.attachment && hoax.attachment.fileType.startsWith('image'); 17 | 18 | const ownedByLoggedInUser = user.id === props.loggedInUser.id; 19 | 20 | let dropDownClass = 'p-0 shadow dropdown-menu'; 21 | if (dropDownVisible) { 22 | dropDownClass += ' show'; 23 | } 24 | 25 | return ( 26 |
27 |
28 | 34 |
35 | 36 |
37 | {displayName}@{username} 38 |
39 | 40 | - 41 | {relativeDate} 42 |
43 | {ownedByLoggedInUser && ( 44 |
45 | 50 |
51 | 57 |
58 |
59 | )} 60 |
61 |
{hoax.content}
62 | {attachmentImageVisible && ( 63 |
64 | attachment 69 |
70 | )} 71 |
72 | ); 73 | }; 74 | 75 | const mapStateToProps = (state) => { 76 | return { 77 | loggedInUser: state, 78 | }; 79 | }; 80 | 81 | export default connect(mapStateToProps)(HoaxView); 82 | -------------------------------------------------------------------------------- /hoaxify-backend/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 2.4.0 10 | 11 | 12 | com.hoaxify 13 | hoaxify 14 | 0.0.1-SNAPSHOT 15 | hoaxify 16 | Demo project for Spring Boot 17 | 18 | 19 | 1.8 20 | 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-data-jpa 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-security 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-web 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-starter-validation 38 | 39 | 40 | org.apache.httpcomponents 41 | httpclient 42 | 43 | 44 | org.apache.tika 45 | tika-core 46 | 1.22 47 | 48 | 49 | commons-io 50 | commons-io 51 | 2.6 52 | 53 | 54 | com.h2database 55 | h2 56 | runtime 57 | 58 | 59 | org.projectlombok 60 | lombok 61 | true 62 | 63 | 64 | org.springframework.boot 65 | spring-boot-starter-test 66 | test 67 | 68 | 69 | org.springframework.security 70 | spring-security-test 71 | test 72 | 73 | 74 | org.springframework.boot 75 | spring-boot-configuration-processor 76 | true 77 | 78 | 79 | 80 | 81 | 82 | 83 | org.springframework.boot 84 | spring-boot-maven-plugin 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /frontend/src/components/TopBar.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import logo from '../assets/hoaxify-logo.png'; 3 | import { Link } from 'react-router-dom'; 4 | import { connect } from 'react-redux'; 5 | import ProfileImageWithDefault from './ProfileImageWithDefault'; 6 | import useClickTracker from '../shared/useClickTracker'; 7 | 8 | const TopBar = (props) => { 9 | const actionArea = useRef(); 10 | const dropDownVisible = useClickTracker(actionArea); 11 | 12 | const onClickLogout = () => { 13 | const action = { 14 | type: 'logout-success', 15 | }; 16 | props.dispatch(action); 17 | }; 18 | 19 | let links = ( 20 |
    21 |
  • 22 | 23 | Sign Up 24 | 25 |
  • 26 |
  • 27 | 28 | Login 29 | 30 |
  • 31 |
32 | ); 33 | if (props.user.isLoggedIn) { 34 | let dropDownClass = 'p-0 shadow dropdown-menu'; 35 | if (dropDownVisible) { 36 | dropDownClass += ' show'; 37 | } 38 | links = ( 39 |
    40 |
  • 41 |
    42 | 48 | 49 | {props.user.displayName} 50 | 51 |
    52 |
    53 | 54 | My Profile 55 | 56 | 63 | Logout 64 | 65 |
    66 |
  • 67 |
68 | ); 69 | } 70 | return ( 71 |
72 |
73 | 79 |
80 |
81 | ); 82 | }; 83 | 84 | const mapStateToProps = (state) => { 85 | return { 86 | user: state, 87 | }; 88 | }; 89 | 90 | export default connect(mapStateToProps)(TopBar); 91 | -------------------------------------------------------------------------------- /frontend/src/pages/LoginPage.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Input from '../components/Input'; 3 | import ButtonWithProgress from '../components/ButtonWithProgress'; 4 | import { connect } from 'react-redux'; 5 | import * as authActions from '../redux/authActions'; 6 | 7 | export const LoginPage = (props) => { 8 | const [username, setUsername] = useState(''); 9 | const [password, setPassword] = useState(''); 10 | const [apiError, setApiError] = useState(); 11 | const [pendingApiCall, setPendingApiCall] = useState(false); 12 | 13 | useEffect(() => { 14 | setApiError(); 15 | }, [username, password]); 16 | 17 | const onClickLogin = () => { 18 | const body = { 19 | username, 20 | password 21 | }; 22 | setPendingApiCall(true); 23 | props.actions 24 | .postLogin(body) 25 | .then((response) => { 26 | setPendingApiCall(false); 27 | props.history.push('/'); 28 | }) 29 | .catch((error) => { 30 | if (error.response) { 31 | setPendingApiCall(false); 32 | setApiError(error.response.data.message); 33 | } 34 | }); 35 | }; 36 | 37 | let disableSubmit = false; 38 | if (username === '') { 39 | disableSubmit = true; 40 | } 41 | if (password === '') { 42 | disableSubmit = true; 43 | } 44 | 45 | return ( 46 |
47 |

Login

48 |
49 | { 54 | setUsername(event.target.value); 55 | }} 56 | /> 57 |
58 |
59 | setPassword(event.target.value)} 65 | /> 66 |
67 | {apiError && ( 68 |
69 |
{apiError}
70 |
71 | )} 72 |
73 | 79 |
80 |
81 | ); 82 | }; 83 | 84 | LoginPage.defaultProps = { 85 | actions: { 86 | postLogin: () => new Promise((resolve, reject) => resolve({})) 87 | }, 88 | dispatch: () => {} 89 | }; 90 | 91 | const mapDispatchToProps = (dispatch) => { 92 | return { 93 | actions: { 94 | postLogin: (body) => dispatch(authActions.loginHandler(body)) 95 | } 96 | }; 97 | }; 98 | 99 | export default connect(null, mapDispatchToProps)(LoginPage); 100 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/hoax/HoaxController.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.hoax; 2 | 3 | import java.util.Collections; 4 | import java.util.List; 5 | import java.util.stream.Collectors; 6 | 7 | import javax.validation.Valid; 8 | 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.data.domain.Page; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.security.access.prepost.PreAuthorize; 14 | import org.springframework.web.bind.annotation.DeleteMapping; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.PathVariable; 17 | import org.springframework.web.bind.annotation.PostMapping; 18 | import org.springframework.web.bind.annotation.RequestBody; 19 | import org.springframework.web.bind.annotation.RequestMapping; 20 | import org.springframework.web.bind.annotation.RequestParam; 21 | import org.springframework.web.bind.annotation.RestController; 22 | 23 | import com.hoaxify.hoaxify.hoax.vm.HoaxVM; 24 | import com.hoaxify.hoaxify.shared.CurrentUser; 25 | import com.hoaxify.hoaxify.shared.GenericResponse; 26 | import com.hoaxify.hoaxify.user.User; 27 | 28 | @RestController 29 | @RequestMapping("/api/1.0") 30 | public class HoaxController { 31 | 32 | @Autowired 33 | HoaxService hoaxService; 34 | 35 | @PostMapping("/hoaxes") 36 | HoaxVM createHoax(@Valid @RequestBody Hoax hoax, @CurrentUser User user) { 37 | return new HoaxVM(hoaxService.save(user, hoax)); 38 | } 39 | 40 | @GetMapping("/hoaxes") 41 | Page getAllHoaxes(Pageable pageable) { 42 | return hoaxService.getAllHoaxes(pageable).map(HoaxVM::new); 43 | } 44 | 45 | @GetMapping("/users/{username}/hoaxes") 46 | Page getHoaxesOfUser(@PathVariable String username, Pageable pageable) { 47 | return hoaxService.getHoaxesOfUser(username, pageable).map(HoaxVM::new); 48 | 49 | } 50 | 51 | @GetMapping({"/hoaxes/{id:[0-9]+}", "/users/{username}/hoaxes/{id:[0-9]+}"}) 52 | ResponseEntity getHoaxesRelative(@PathVariable long id, 53 | @PathVariable(required= false) String username, 54 | Pageable pageable, 55 | @RequestParam(name="direction", defaultValue="after") String direction, 56 | @RequestParam(name="count", defaultValue="false", required=false) boolean count 57 | ) { 58 | if(!direction.equalsIgnoreCase("after")) { 59 | return ResponseEntity.ok(hoaxService.getOldHoaxes(id, username, pageable).map(HoaxVM::new)); 60 | } 61 | 62 | if(count == true) { 63 | long newHoaxCount = hoaxService.getNewHoaxesCount(id, username); 64 | return ResponseEntity.ok(Collections.singletonMap("count", newHoaxCount)); 65 | } 66 | 67 | List newHoaxes = hoaxService.getNewHoaxes(id, username, pageable).stream() 68 | .map(HoaxVM::new).collect(Collectors.toList()); 69 | return ResponseEntity.ok(newHoaxes); 70 | } 71 | 72 | @DeleteMapping("/hoaxes/{id:[0-9]+}") 73 | @PreAuthorize("@hoaxSecurityService.isAllowedToDelete(#id, principal)") 74 | GenericResponse deleteHoax(@PathVariable long id) { 75 | hoaxService.deleteHoax(id); 76 | return new GenericResponse("Hoax is removed"); 77 | } 78 | 79 | 80 | 81 | 82 | } 83 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/file/FileService.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.file; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.nio.file.Files; 6 | import java.nio.file.Paths; 7 | import java.util.Base64; 8 | import java.util.Date; 9 | import java.util.List; 10 | import java.util.UUID; 11 | 12 | import org.apache.commons.io.FileUtils; 13 | import org.apache.tika.Tika; 14 | import org.springframework.scheduling.annotation.EnableScheduling; 15 | import org.springframework.scheduling.annotation.Scheduled; 16 | import org.springframework.stereotype.Service; 17 | import org.springframework.web.multipart.MultipartFile; 18 | 19 | import com.hoaxify.hoaxify.configuration.AppConfiguration; 20 | 21 | @Service 22 | @EnableScheduling 23 | public class FileService { 24 | 25 | AppConfiguration appConfiguration; 26 | 27 | Tika tika; 28 | 29 | FileAttachmentRepository fileAttachmentRepository; 30 | 31 | public FileService(AppConfiguration appConfiguration, FileAttachmentRepository fileAttachmentRepository) { 32 | super(); 33 | this.appConfiguration = appConfiguration; 34 | this.fileAttachmentRepository = fileAttachmentRepository; 35 | tika = new Tika(); 36 | } 37 | 38 | public String saveProfileImage(String base64Image) throws IOException { 39 | String imageName = getRandomName(); 40 | 41 | byte[] decodedBytes = Base64.getDecoder().decode(base64Image); 42 | File target = new File(appConfiguration.getFullProfileImagesPath() + "/" + imageName); 43 | FileUtils.writeByteArrayToFile(target, decodedBytes); 44 | return imageName; 45 | } 46 | 47 | private String getRandomName() { 48 | return UUID.randomUUID().toString().replaceAll("-", ""); 49 | } 50 | 51 | public String detectType(byte[] fileArr) { 52 | return tika.detect(fileArr); 53 | } 54 | 55 | public void deleteProfileImage(String image) { 56 | try { 57 | Files.deleteIfExists(Paths.get(appConfiguration.getFullProfileImagesPath()+"/"+image)); 58 | } catch (IOException e) { 59 | e.printStackTrace(); 60 | } 61 | 62 | } 63 | 64 | public FileAttachment saveAttachment(MultipartFile file) { 65 | FileAttachment fileAttachment = new FileAttachment(); 66 | fileAttachment.setDate(new Date()); 67 | String randomName = getRandomName(); 68 | fileAttachment.setName(randomName); 69 | 70 | File target = new File(appConfiguration.getFullAttachmentsPath() +"/"+randomName); 71 | try { 72 | byte[] fileAsByte = file.getBytes(); 73 | FileUtils.writeByteArrayToFile(target, fileAsByte); 74 | fileAttachment.setFileType(detectType(fileAsByte)); 75 | } catch (IOException e) { 76 | e.printStackTrace(); 77 | } 78 | 79 | return fileAttachmentRepository.save(fileAttachment); 80 | } 81 | 82 | @Scheduled(fixedRate = 60 * 60 * 1000) 83 | public void cleanupStorage() { 84 | Date oneHourAgo = new Date(System.currentTimeMillis() - (60*60*1000)); 85 | List oldFiles = fileAttachmentRepository.findByDateBeforeAndHoaxIsNull(oneHourAgo); 86 | for(FileAttachment file: oldFiles) { 87 | deleteAttachmentImage(file.getName()); 88 | fileAttachmentRepository.deleteById(file.getId()); 89 | } 90 | 91 | } 92 | 93 | public void deleteAttachmentImage(String image) { 94 | try { 95 | Files.deleteIfExists(Paths.get(appConfiguration.getFullAttachmentsPath()+"/"+image)); 96 | } catch (IOException e) { 97 | e.printStackTrace(); 98 | } 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /frontend/src/components/Input.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import Input from './Input'; 4 | 5 | describe('Layout', () => { 6 | it('has input item', () => { 7 | const { container } = render(); 8 | const input = container.querySelector('input'); 9 | expect(input).toBeInTheDocument(); 10 | }); 11 | 12 | it('displays the label provided in props', () => { 13 | const { queryByText } = render(); 14 | const label = queryByText('Test label'); 15 | expect(label).toBeInTheDocument(); 16 | }); 17 | 18 | it('does not displays the label when no label provided in props', () => { 19 | const { container } = render(); 20 | const label = container.querySelector('label'); 21 | expect(label).not.toBeInTheDocument(); 22 | }); 23 | 24 | it('has text type for input when type is not provided as prop', () => { 25 | const { container } = render(); 26 | const input = container.querySelector('input'); 27 | expect(input.type).toBe('text'); 28 | }); 29 | 30 | it('has password type for input password type is provided as prop', () => { 31 | const { container } = render(); 32 | const input = container.querySelector('input'); 33 | expect(input.type).toBe('password'); 34 | }); 35 | 36 | it('displays placeholder when it is provided as prop', () => { 37 | const { container } = render(); 38 | const input = container.querySelector('input'); 39 | expect(input.placeholder).toBe('Test placeholder'); 40 | }); 41 | 42 | it('has value for input when it is provided as prop', () => { 43 | const { container } = render(); 44 | const input = container.querySelector('input'); 45 | expect(input.value).toBe('Test value'); 46 | }); 47 | 48 | it('has onChange callback when it is provided as prop', () => { 49 | const onChange = jest.fn(); 50 | const { container } = render(); 51 | const input = container.querySelector('input'); 52 | fireEvent.change(input, { target: { value: 'new-input' } }); 53 | expect(onChange).toHaveBeenCalledTimes(1); 54 | }); 55 | 56 | it('has default style when there is no validation error or success', () => { 57 | const { container } = render(); 58 | const input = container.querySelector('input'); 59 | expect(input.className).toBe('form-control'); 60 | }); 61 | 62 | it('has success style when hasError property is false', () => { 63 | const { container } = render(); 64 | const input = container.querySelector('input'); 65 | expect(input.className).toBe('form-control is-valid'); 66 | }); 67 | 68 | it('has style for error case when there is error', () => { 69 | const { container } = render(); 70 | const input = container.querySelector('input'); 71 | expect(input.className).toBe('form-control is-invalid'); 72 | }); 73 | 74 | it('displays the error text when it is provided', () => { 75 | const { queryByText } = render( 76 | 77 | ); 78 | expect(queryByText('Cannot be null')).toBeInTheDocument(); 79 | }); 80 | 81 | it('does not display the error text when hasError not provided', () => { 82 | const { queryByText } = render(); 83 | expect(queryByText('Cannot be null')).not.toBeInTheDocument(); 84 | }); 85 | 86 | it('has form-control-file class when type is file', () => { 87 | const { container } = render(); 88 | const input = container.querySelector('input'); 89 | expect(input.className).toBe('form-control-file'); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /hoaxify-backend/src/main/java/com/hoaxify/hoaxify/hoax/HoaxService.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify.hoax; 2 | 3 | import java.util.Date; 4 | import java.util.List; 5 | 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.jpa.domain.Specification; 9 | import org.springframework.stereotype.Service; 10 | 11 | import com.hoaxify.hoaxify.file.FileAttachment; 12 | import com.hoaxify.hoaxify.file.FileAttachmentRepository; 13 | import com.hoaxify.hoaxify.file.FileService; 14 | import com.hoaxify.hoaxify.user.User; 15 | import com.hoaxify.hoaxify.user.UserService; 16 | 17 | @Service 18 | public class HoaxService { 19 | 20 | HoaxRepository hoaxRepository; 21 | 22 | UserService userService; 23 | 24 | FileAttachmentRepository fileAttachmentRepository; 25 | 26 | FileService fileService; 27 | 28 | public HoaxService(HoaxRepository hoaxRepository, UserService userService, 29 | FileAttachmentRepository fileAttachmentRepository, FileService fileService) { 30 | super(); 31 | this.hoaxRepository = hoaxRepository; 32 | this.userService = userService; 33 | this.fileAttachmentRepository = fileAttachmentRepository; 34 | this.fileService = fileService; 35 | } 36 | 37 | public Hoax save(User user, Hoax hoax) { 38 | hoax.setTimestamp(new Date()); 39 | hoax.setUser(user); 40 | if(hoax.getAttachment() != null) { 41 | FileAttachment inDB = fileAttachmentRepository.findById(hoax.getAttachment().getId()).get(); 42 | inDB.setHoax(hoax); 43 | hoax.setAttachment(inDB); 44 | } 45 | return hoaxRepository.save(hoax); 46 | } 47 | 48 | public Page getAllHoaxes(Pageable pageable) { 49 | return hoaxRepository.findAll(pageable); 50 | } 51 | 52 | public Page getHoaxesOfUser(String username, Pageable pageable) { 53 | User inDB = userService.getByUsername(username); 54 | return hoaxRepository.findByUser(inDB, pageable); 55 | } 56 | 57 | public Page getOldHoaxes(long id, String username, Pageable pageable) { 58 | Specification spec = Specification.where(idLessThan(id)); 59 | if(username != null) { 60 | User inDB = userService.getByUsername(username); 61 | spec = spec.and(userIs(inDB)); 62 | } 63 | return hoaxRepository.findAll(spec, pageable); 64 | } 65 | 66 | 67 | public List getNewHoaxes(long id, String username, Pageable pageable) { 68 | Specification spec = Specification.where(idGreaterThan(id)); 69 | if(username != null) { 70 | User inDB = userService.getByUsername(username); 71 | spec = spec.and(userIs(inDB)); 72 | } 73 | return hoaxRepository.findAll(spec, pageable.getSort()); 74 | } 75 | 76 | public long getNewHoaxesCount(long id, String username) { 77 | Specification spec = Specification.where(idGreaterThan(id)); 78 | if(username != null) { 79 | User inDB = userService.getByUsername(username); 80 | spec = spec.and(userIs(inDB)); 81 | } 82 | return hoaxRepository.count(spec); 83 | } 84 | 85 | private Specification userIs(User user){ 86 | return (root, query, criteriaBuilder) -> { 87 | return criteriaBuilder.equal(root.get("user"), user); 88 | }; 89 | } 90 | 91 | private Specification idLessThan(long id){ 92 | return (root, query, criteriaBuilder) -> { 93 | return criteriaBuilder.lessThan(root.get("id"), id); 94 | }; 95 | } 96 | 97 | private Specification idGreaterThan(long id){ 98 | return (root, query, criteriaBuilder) -> { 99 | return criteriaBuilder.greaterThan(root.get("id"), id); 100 | }; 101 | } 102 | 103 | public void deleteHoax(long id) { 104 | Hoax hoax = hoaxRepository.getOne(id); 105 | if(hoax.getAttachment() != null) { 106 | fileService.deleteAttachmentImage(hoax.getAttachment().getName()); 107 | } 108 | hoaxRepository.deleteById(id); 109 | 110 | } 111 | 112 | 113 | 114 | 115 | } 116 | -------------------------------------------------------------------------------- /hoaxify-backend/src/test/java/com/hoaxify/hoaxify/FileServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.io.File; 6 | import java.io.IOException; 7 | import java.util.Arrays; 8 | import java.util.Date; 9 | 10 | import org.apache.commons.io.FileUtils; 11 | import org.junit.jupiter.api.AfterEach; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.Test; 14 | import org.junit.jupiter.api.extension.ExtendWith; 15 | import org.mockito.Mockito; 16 | import org.springframework.boot.test.mock.mockito.MockBean; 17 | import org.springframework.core.io.ClassPathResource; 18 | import org.springframework.test.context.ActiveProfiles; 19 | import org.springframework.test.context.junit.jupiter.SpringExtension; 20 | 21 | import com.hoaxify.hoaxify.configuration.AppConfiguration; 22 | import com.hoaxify.hoaxify.file.FileAttachment; 23 | import com.hoaxify.hoaxify.file.FileAttachmentRepository; 24 | import com.hoaxify.hoaxify.file.FileService; 25 | 26 | @ExtendWith(SpringExtension.class) 27 | @ActiveProfiles("test") 28 | public class FileServiceTest { 29 | 30 | FileService fileService; 31 | 32 | AppConfiguration appConfiguration; 33 | 34 | @MockBean 35 | FileAttachmentRepository fileAttachmentRepository; 36 | 37 | @BeforeEach 38 | public void init() { 39 | appConfiguration = new AppConfiguration(); 40 | appConfiguration.setUploadPath("uploads-test"); 41 | 42 | fileService = new FileService(appConfiguration, fileAttachmentRepository); 43 | 44 | new File(appConfiguration.getUploadPath()).mkdir(); 45 | new File(appConfiguration.getFullProfileImagesPath()).mkdir(); 46 | new File(appConfiguration.getFullAttachmentsPath()).mkdir(); 47 | } 48 | 49 | @Test 50 | public void detectType_whenPngFileProvided_returnsImagePng() throws IOException { 51 | ClassPathResource resourceFile = new ClassPathResource("test-png.png"); 52 | byte[] fileArr = FileUtils.readFileToByteArray(resourceFile.getFile()); 53 | String fileType = fileService.detectType(fileArr); 54 | assertThat(fileType).isEqualToIgnoringCase("image/png"); 55 | } 56 | 57 | @Test 58 | public void cleanupStorage_whenOldFilesExist_remoevsFilesFromStorage() throws IOException { 59 | String fileName = "random-file"; 60 | String filePath = appConfiguration.getFullAttachmentsPath() + "/" + fileName; 61 | File source = new ClassPathResource("profile.png").getFile(); 62 | File target = new File(filePath); 63 | FileUtils.copyFile(source, target); 64 | 65 | FileAttachment fileAttachment = new FileAttachment(); 66 | fileAttachment.setId(5); 67 | fileAttachment.setName(fileName); 68 | 69 | Mockito.when(fileAttachmentRepository.findByDateBeforeAndHoaxIsNull(Mockito.any(Date.class))) 70 | .thenReturn(Arrays.asList(fileAttachment)); 71 | 72 | fileService.cleanupStorage(); 73 | File storedImage = new File(filePath); 74 | assertThat(storedImage.exists()).isFalse(); 75 | } 76 | 77 | @Test 78 | public void cleanupStorage_whenOldFilesExist_remoevsFileAttachmentFromDatabase() throws IOException { 79 | String fileName = "random-file"; 80 | String filePath = appConfiguration.getFullAttachmentsPath() + "/" + fileName; 81 | File source = new ClassPathResource("profile.png").getFile(); 82 | File target = new File(filePath); 83 | FileUtils.copyFile(source, target); 84 | 85 | FileAttachment fileAttachment = new FileAttachment(); 86 | fileAttachment.setId(5); 87 | fileAttachment.setName(fileName); 88 | 89 | Mockito.when(fileAttachmentRepository.findByDateBeforeAndHoaxIsNull(Mockito.any(Date.class))) 90 | .thenReturn(Arrays.asList(fileAttachment)); 91 | 92 | fileService.cleanupStorage(); 93 | Mockito.verify(fileAttachmentRepository).deleteById(5L); 94 | } 95 | 96 | 97 | @AfterEach 98 | public void cleanup() throws IOException { 99 | FileUtils.cleanDirectory(new File(appConfiguration.getFullProfileImagesPath())); 100 | FileUtils.cleanDirectory(new File(appConfiguration.getFullAttachmentsPath())); 101 | 102 | } 103 | 104 | 105 | 106 | 107 | } 108 | -------------------------------------------------------------------------------- /hoaxify-backend/src/test/java/com/hoaxify/hoaxify/FileAttachmentRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.util.Date; 6 | import java.util.List; 7 | 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 11 | import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; 12 | import org.springframework.test.context.ActiveProfiles; 13 | 14 | import com.hoaxify.hoaxify.file.FileAttachment; 15 | import com.hoaxify.hoaxify.file.FileAttachmentRepository; 16 | import com.hoaxify.hoaxify.hoax.Hoax; 17 | 18 | @DataJpaTest 19 | @ActiveProfiles("test") 20 | public class FileAttachmentRepositoryTest { 21 | 22 | @Autowired 23 | TestEntityManager testEntityManager; 24 | 25 | @Autowired 26 | FileAttachmentRepository fileAttachmentRepository; 27 | 28 | @Test 29 | public void findByDateBeforeAndHoaxIsNull_whenAttachmentsDateOlderThanOneHour_returnsAll() { 30 | testEntityManager.persist(getOneHourOldFileAttachment()); 31 | testEntityManager.persist(getOneHourOldFileAttachment()); 32 | testEntityManager.persist(getOneHourOldFileAttachment()); 33 | Date oneHourAgo = new Date(System.currentTimeMillis() - (60*60*1000)); 34 | List attachments = fileAttachmentRepository.findByDateBeforeAndHoaxIsNull(oneHourAgo); 35 | assertThat(attachments.size()).isEqualTo(3); 36 | } 37 | 38 | @Test 39 | public void findByDateBeforeAndHoaxIsNull_whenAttachmentsDateOlderThanOneHorButHaveHoax_returnsNone() { 40 | Hoax hoax1 = testEntityManager.persist(TestUtil.createValidHoax()); 41 | Hoax hoax2 = testEntityManager.persist(TestUtil.createValidHoax()); 42 | Hoax hoax3 = testEntityManager.persist(TestUtil.createValidHoax()); 43 | 44 | testEntityManager.persist(getOldFileAttachmentWithHoax(hoax1)); 45 | testEntityManager.persist(getOldFileAttachmentWithHoax(hoax2)); 46 | testEntityManager.persist(getOldFileAttachmentWithHoax(hoax3)); 47 | Date oneHourAgo = new Date(System.currentTimeMillis() - (60*60*1000)); 48 | List attachments = fileAttachmentRepository.findByDateBeforeAndHoaxIsNull(oneHourAgo); 49 | assertThat(attachments.size()).isEqualTo(0); 50 | } 51 | 52 | @Test 53 | public void findByDateBeforeAndHoaxIsNull_whenAttachmentsDateWithinOneHour_returnsNone() { 54 | testEntityManager.persist(getFileAttachmentWithinOneHour()); 55 | testEntityManager.persist(getFileAttachmentWithinOneHour()); 56 | testEntityManager.persist(getFileAttachmentWithinOneHour()); 57 | Date oneHourAgo = new Date(System.currentTimeMillis() - (60*60*1000)); 58 | List attachments = fileAttachmentRepository.findByDateBeforeAndHoaxIsNull(oneHourAgo); 59 | assertThat(attachments.size()).isEqualTo(0); 60 | } 61 | 62 | @Test 63 | public void findByDateBeforeAndHoaxIsNull_whenSomeAttachmentsOldSomeNewAndSomeWithHoax_returnsAttachmentsWithOlderAndNoHoaxAssigned() { 64 | Hoax hoax1 = testEntityManager.persist(TestUtil.createValidHoax()); 65 | testEntityManager.persist(getOldFileAttachmentWithHoax(hoax1)); 66 | testEntityManager.persist(getOneHourOldFileAttachment()); 67 | testEntityManager.persist(getFileAttachmentWithinOneHour()); 68 | Date oneHourAgo = new Date(System.currentTimeMillis() - (60*60*1000)); 69 | List attachments = fileAttachmentRepository.findByDateBeforeAndHoaxIsNull(oneHourAgo); 70 | assertThat(attachments.size()).isEqualTo(1); 71 | } 72 | private FileAttachment getOneHourOldFileAttachment() { 73 | Date date = new Date(System.currentTimeMillis() - (60*60*1000) - 1); 74 | FileAttachment fileAttachment = new FileAttachment(); 75 | fileAttachment.setDate(date); 76 | return fileAttachment; 77 | } 78 | private FileAttachment getFileAttachmentWithinOneHour() { 79 | Date date = new Date(System.currentTimeMillis() - (60*1000)); 80 | FileAttachment fileAttachment = new FileAttachment(); 81 | fileAttachment.setDate(date); 82 | return fileAttachment; 83 | } 84 | 85 | private FileAttachment getOldFileAttachmentWithHoax(Hoax hoax) { 86 | FileAttachment fileAttachment = getOneHourOldFileAttachment(); 87 | fileAttachment.setHoax(hoax); 88 | return fileAttachment; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/pages/UserSignupPage.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Input from '../components/Input'; 3 | import ButtonWithProgress from '../components/ButtonWithProgress'; 4 | import { connect } from 'react-redux'; 5 | import * as authActions from '../redux/authActions'; 6 | 7 | export const UserSignupPage = (props) => { 8 | const [form, setForm] = useState({ 9 | displayName: '', 10 | username: '', 11 | password: '', 12 | passwordRepeat: '' 13 | }); 14 | const [errors, setErrors] = useState({}); 15 | const [pendingApiCall, setPendingApiCall] = useState(false); 16 | 17 | const onChange = (event) => { 18 | const { value, name } = event.target; 19 | 20 | setForm((previousForm) => { 21 | return { 22 | ...previousForm, 23 | [name]: value 24 | }; 25 | }); 26 | 27 | setErrors((previousErrors) => { 28 | return { 29 | ...previousErrors, 30 | [name]: undefined 31 | }; 32 | }); 33 | }; 34 | 35 | const onClickSignup = () => { 36 | const user = { 37 | username: form.username, 38 | displayName: form.displayName, 39 | password: form.password 40 | }; 41 | setPendingApiCall(true); 42 | props.actions 43 | .postSignup(user) 44 | .then((response) => { 45 | setPendingApiCall(false); 46 | props.history.push('/'); 47 | }) 48 | .catch((apiError) => { 49 | if (apiError.response.data && apiError.response.data.validationErrors) { 50 | setErrors(apiError.response.data.validationErrors); 51 | } 52 | setPendingApiCall(false); 53 | }); 54 | }; 55 | 56 | let passwordRepeatError; 57 | const { password, passwordRepeat } = form; 58 | if (password || passwordRepeat) { 59 | passwordRepeatError = 60 | password === passwordRepeat ? '' : 'Does not match to password'; 61 | } 62 | 63 | return ( 64 |
65 |

Sign Up

66 |
67 | 76 |
77 |
78 | 87 |
88 |
89 | 99 |
100 |
101 | 111 |
112 |
113 | 119 |
120 |
121 | ); 122 | }; 123 | 124 | UserSignupPage.defaultProps = { 125 | actions: { 126 | postSignup: () => 127 | new Promise((resolve, reject) => { 128 | resolve({}); 129 | }) 130 | }, 131 | history: { 132 | push: () => {} 133 | } 134 | }; 135 | 136 | const mapDispatchToProps = (dispatch) => { 137 | return { 138 | actions: { 139 | postSignup: (user) => dispatch(authActions.signupHandler(user)) 140 | } 141 | }; 142 | }; 143 | 144 | export default connect(null, mapDispatchToProps)(UserSignupPage); 145 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileCard.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import ProfileCard from './ProfileCard'; 4 | const user = { 5 | id: 1, 6 | username: 'user1', 7 | displayName: 'display1', 8 | image: 'profile1.png' 9 | }; 10 | 11 | describe('ProfileCard', () => { 12 | describe('Layout', () => { 13 | it('displays the displayName@username', () => { 14 | const { queryByText } = render(); 15 | const userInfo = queryByText('display1@user1'); 16 | expect(userInfo).toBeInTheDocument(); 17 | }); 18 | it('has image', () => { 19 | const { container } = render(); 20 | const image = container.querySelector('img'); 21 | expect(image).toBeInTheDocument(); 22 | }); 23 | it('displays default image when user does not have one', () => { 24 | const userWithoutImage = { 25 | ...user, 26 | image: undefined 27 | }; 28 | const { container } = render(); 29 | const image = container.querySelector('img'); 30 | expect(image.src).toContain('/profile.png'); 31 | }); 32 | it('displays user image when user has one', () => { 33 | const { container } = render(); 34 | const image = container.querySelector('img'); 35 | expect(image.src).toContain('/images/profile/' + user.image); 36 | }); 37 | it('displays edit button when isEditable property set as true', () => { 38 | const { queryByText } = render( 39 | 40 | ); 41 | const editButton = queryByText('Edit'); 42 | expect(editButton).toBeInTheDocument(); 43 | }); 44 | it('does not display edit button when isEditable not provided', () => { 45 | const { queryByText } = render(); 46 | const editButton = queryByText('Edit'); 47 | expect(editButton).not.toBeInTheDocument(); 48 | }); 49 | it('displays displayName input when inEditMode property set as true', () => { 50 | const { container } = render( 51 | 52 | ); 53 | const displayInput = container.querySelector('input'); 54 | expect(displayInput).toBeInTheDocument(); 55 | }); 56 | it('displays the current displayName in input in edit mode', () => { 57 | const { container } = render( 58 | 59 | ); 60 | const displayInput = container.querySelector('input'); 61 | expect(displayInput.value).toBe(user.displayName); 62 | }); 63 | it('hides the displayName@username in edit mode', () => { 64 | const { queryByText } = render( 65 | 66 | ); 67 | const userInfo = queryByText('display1@user1'); 68 | expect(userInfo).not.toBeInTheDocument(); 69 | }); 70 | it('displays label for displayName in edit mode', () => { 71 | const { container } = render( 72 | 73 | ); 74 | const label = container.querySelector('label'); 75 | expect(label).toHaveTextContent('Change Display Name for user1'); 76 | }); 77 | it('hides the edit button in edit mode and isEditable provided as true', () => { 78 | const { queryByText } = render( 79 | 80 | ); 81 | const editButton = queryByText('Edit'); 82 | expect(editButton).not.toBeInTheDocument(); 83 | }); 84 | it('displays Save button in edit mode', () => { 85 | const { queryByText } = render( 86 | 87 | ); 88 | const saveButton = queryByText('Save'); 89 | expect(saveButton).toBeInTheDocument(); 90 | }); 91 | it('displays Cancel button in edit mode', () => { 92 | const { queryByText } = render( 93 | 94 | ); 95 | const cancelButton = queryByText('Cancel'); 96 | expect(cancelButton).toBeInTheDocument(); 97 | }); 98 | it('displays file input when inEditMode property set as true', () => { 99 | const { container } = render( 100 | 101 | ); 102 | const inputs = container.querySelectorAll('input'); 103 | const uploadInput = inputs[1]; 104 | expect(uploadInput.type).toBe('file'); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /hoaxify-backend/src/test/java/com/hoaxify/hoaxify/StaticResourceTest.java: -------------------------------------------------------------------------------- 1 | package com.hoaxify.hoaxify; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | 7 | import java.io.File; 8 | import java.io.IOException; 9 | 10 | import org.apache.commons.io.FileUtils; 11 | import org.junit.jupiter.api.AfterEach; 12 | import org.junit.jupiter.api.Test; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 15 | import org.springframework.boot.test.context.SpringBootTest; 16 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; 17 | import org.springframework.core.io.ClassPathResource; 18 | import org.springframework.test.context.ActiveProfiles; 19 | import org.springframework.test.web.servlet.MockMvc; 20 | import org.springframework.test.web.servlet.MvcResult; 21 | 22 | import com.hoaxify.hoaxify.configuration.AppConfiguration; 23 | 24 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 25 | @ActiveProfiles("test") 26 | @AutoConfigureMockMvc 27 | public class StaticResourceTest { 28 | 29 | @Autowired 30 | AppConfiguration appConfiguration; 31 | 32 | @Autowired 33 | MockMvc mockMvc; 34 | 35 | @Test 36 | public void checkStaticFolder_whenAppIsInitialized_uploadFolderMustExist() { 37 | File uploadFolder = new File(appConfiguration.getUploadPath()); 38 | boolean uploadFolderExist = uploadFolder.exists() && uploadFolder.isDirectory(); 39 | assertThat(uploadFolderExist).isTrue(); 40 | } 41 | 42 | @Test 43 | public void checkStaticFolder_whenAppIsInitialized_profileImageSubFolderMustExist() { 44 | String profileImageFolderPath = appConfiguration.getFullProfileImagesPath(); 45 | File profileImageFolder = new File(profileImageFolderPath); 46 | boolean profileImageFolderExist = profileImageFolder.exists() && profileImageFolder.isDirectory(); 47 | assertThat(profileImageFolderExist).isTrue(); 48 | } 49 | 50 | @Test 51 | public void checkStaticFolder_whenAppIsInitialized_attachmentsSubFolderMustExist() { 52 | String attachmentsFolderPath = appConfiguration.getFullAttachmentsPath(); 53 | File attachmentsFolder = new File(attachmentsFolderPath); 54 | boolean attachmentsFolderExist = attachmentsFolder.exists() && attachmentsFolder.isDirectory(); 55 | assertThat(attachmentsFolderExist).isTrue(); 56 | 57 | } 58 | 59 | @Test 60 | public void getStaticFile_whenImageExistInProfileUploadFolder_receiveOk() throws Exception { 61 | String fileName = "profile-picture.png"; 62 | File source = new ClassPathResource("profile.png").getFile(); 63 | 64 | File target = new File(appConfiguration.getFullProfileImagesPath() + "/" + fileName); 65 | FileUtils.copyFile(source, target); 66 | 67 | mockMvc.perform(get("/images/"+appConfiguration.getProfileImagesFolder()+"/"+fileName)).andExpect(status().isOk()); 68 | 69 | } 70 | 71 | @Test 72 | public void getStaticFile_whenImageExistInAttachmentFolder_receiveOk() throws Exception { 73 | String fileName = "profile-picture.png"; 74 | File source = new ClassPathResource("profile.png").getFile(); 75 | 76 | File target = new File(appConfiguration.getFullAttachmentsPath() + "/" + fileName); 77 | FileUtils.copyFile(source, target); 78 | 79 | mockMvc.perform(get("/images/"+appConfiguration.getAttachmentsFolder()+"/"+fileName)).andExpect(status().isOk()); 80 | 81 | } 82 | 83 | @Test 84 | public void getStaticFile_whenImageDoesNotExist_receiveNotFound() throws Exception { 85 | mockMvc.perform(get("/images/"+appConfiguration.getAttachmentsFolder()+"/there-is-no-such-image.png")) 86 | .andExpect(status().isNotFound()); 87 | } 88 | 89 | @Test 90 | public void getStaticFile_whenImageExistInAttachmentFolder_receiveOkWithCacheHeaders() throws Exception { 91 | String fileName = "profile-picture.png"; 92 | File source = new ClassPathResource("profile.png").getFile(); 93 | 94 | File target = new File(appConfiguration.getFullAttachmentsPath() + "/" + fileName); 95 | FileUtils.copyFile(source, target); 96 | 97 | MvcResult result = mockMvc.perform(get("/images/"+appConfiguration.getAttachmentsFolder()+"/"+fileName)).andReturn(); 98 | 99 | String cacheControl = result.getResponse().getHeaderValue("Cache-Control").toString(); 100 | assertThat(cacheControl).containsIgnoringCase("max-age=31536000"); 101 | 102 | } 103 | 104 | @AfterEach 105 | public void cleanup() throws IOException { 106 | FileUtils.cleanDirectory(new File(appConfiguration.getFullProfileImagesPath())); 107 | FileUtils.cleanDirectory(new File(appConfiguration.getFullAttachmentsPath())); 108 | } 109 | 110 | 111 | 112 | 113 | 114 | } 115 | -------------------------------------------------------------------------------- /frontend/src/components/HoaxSubmit.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ProfileImageWithDefault from './ProfileImageWithDefault'; 3 | import { connect } from 'react-redux'; 4 | import * as apiCalls from '../api/apiCalls'; 5 | import ButtonWithProgress from './ButtonWithProgress'; 6 | import Input from './Input'; 7 | 8 | class HoaxSubmit extends Component { 9 | state = { 10 | focused: false, 11 | content: undefined, 12 | pendingApiCall: false, 13 | errors: {}, 14 | file: undefined, 15 | image: undefined, 16 | attachment: undefined 17 | }; 18 | 19 | onChangeContent = (event) => { 20 | const value = event.target.value; 21 | this.setState({ content: value, errors: {} }); 22 | }; 23 | 24 | onFileSelect = (event) => { 25 | if (event.target.files.length === 0) { 26 | return; 27 | } 28 | const file = event.target.files[0]; 29 | let reader = new FileReader(); 30 | reader.onloadend = () => { 31 | this.setState( 32 | { 33 | image: reader.result, 34 | file 35 | }, 36 | () => { 37 | this.uploadFile(); 38 | } 39 | ); 40 | }; 41 | reader.readAsDataURL(file); 42 | }; 43 | 44 | uploadFile = () => { 45 | const body = new FormData(); 46 | body.append('file', this.state.file); 47 | apiCalls.postHoaxFile(body).then((response) => { 48 | this.setState({ attachment: response.data }); 49 | }); 50 | }; 51 | 52 | resetState = () => { 53 | this.setState({ 54 | pendingApiCall: false, 55 | focused: false, 56 | content: '', 57 | errors: {}, 58 | image: undefined, 59 | file: undefined, 60 | attachment: undefined 61 | }); 62 | }; 63 | 64 | onClickHoaxify = () => { 65 | const body = { 66 | content: this.state.content, 67 | attachment: this.state.attachment 68 | }; 69 | this.setState({ pendingApiCall: true }); 70 | apiCalls 71 | .postHoax(body) 72 | .then((response) => { 73 | this.resetState(); 74 | }) 75 | .catch((error) => { 76 | let errors = {}; 77 | if (error.response.data && error.response.data.validationErrors) { 78 | errors = error.response.data.validationErrors; 79 | } 80 | this.setState({ pendingApiCall: false, errors }); 81 | }); 82 | }; 83 | 84 | onFocus = () => { 85 | this.setState({ 86 | focused: true 87 | }); 88 | }; 89 | 90 | render() { 91 | let textAreaClassName = 'form-control w-100'; 92 | if (this.state.errors.content) { 93 | textAreaClassName += ' is-invalid'; 94 | } 95 | return ( 96 |
97 | 103 |
104 |