Skip to content

Commit

Permalink
Merge pull request #5 from jasonpyau/dev
Browse files Browse the repository at this point in the history
Add ability to view and upload images to chats
  • Loading branch information
jasonpyau authored Jan 5, 2025
2 parents 2542acb + a5a803e commit 67166f7
Show file tree
Hide file tree
Showing 30 changed files with 778 additions and 41 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ OAUTH_GITHUB_CLIENT_ID=YOUR_OAUTH_GITHUB_CLIENT_ID_HERE
OAUTH_GITHUB_CLIENT_SECRET=YOUR_OAUTH_GITHUB_CLIENT_SECRET_HERE
OAUTH_DISCORD_CLIENT_ID=YOUR_OAUTH_DISCORD_CLIENT_SECRET_HERE
OAUTH_DISCORD_CLIENT_SECRET=YOUR_OAUTH_DISCORD_CLIENT_SECRET_HERE
AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID_HERE
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY_HERE
AWS_ENDPOINT=YOUR_AWS_ENDPOINT_HERE
AWS_BUCKET=YOUR_AWS_BUCKET_HERE
```

**Run Spring Boot Project**
Expand Down
15 changes: 15 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@
<artifactId>guava</artifactId>
<version>32.0.1-jre</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.29.43</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import com.jasonpyau.chatapp.entity.User.Role;
import com.jasonpyau.chatapp.security.CustomOAuth2AuthenticationFailureHandler;
import com.jasonpyau.chatapp.security.CustomOAuth2AuthenticationSuccessHandler;
import com.jasonpyau.chatapp.security.CustomOAuth2UserService;
import com.jasonpyau.chatapp.service.CustomOAuth2UserService;

@Configuration
@EnableWebSecurity
Expand Down Expand Up @@ -39,7 +39,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.authorizeHttpRequests(auth -> {
auth
.requestMatchers("/new_user", "/api/login/new_user").hasRole(Role.NEW_USER.toString())
.requestMatchers("/api/groupchat/**", "/api/message/**", "/api/users/**").hasAnyRole(Role.USER.toString(), Role.ADMIN.toString())
.requestMatchers("/api/groupchat/**", "/api/message/**", "/api/users/**", "/api/attachment/**").hasAnyRole(Role.USER.toString(), Role.ADMIN.toString())
.requestMatchers("/topic/**", "/app/**", "/ws/**").hasAnyRole(Role.USER.toString(), Role.ADMIN.toString())
.requestMatchers("/built/**").permitAll()
.requestMatchers("/", "/error", "login", "logout", "/api/login/user", "/api/login/principal").permitAll()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;

import com.jasonpyau.chatapp.entity.Attachment;

@Configuration
@EnableWebSocketMessageBroker
Expand All @@ -32,4 +35,12 @@ public void configureMessageBroker(MessageBrokerRegistry registry) {
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authChannelInterceptor);
}

@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
// The current size limit for files is 10MB.
// Since we receive files from the socket via base64 data URLs, the file size limit should be 10MB * 4 / 3.
// So let's make it 20 MB limit to be safe, then we can validate that the file itself is actually 10 MB before saving the file to Amazon S3.
registry.setMessageSizeLimit(2*Attachment.SIZE_LIMIT_IN_MB*1024*1024);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.jasonpyau.chatapp.controller;

import java.time.Duration;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import com.jasonpyau.chatapp.annotation.GetUser;
import com.jasonpyau.chatapp.annotation.RateLimitAPI;
import com.jasonpyau.chatapp.entity.User;
import com.jasonpyau.chatapp.service.AttachmentService;
import com.jasonpyau.chatapp.service.RateLimitService.Token;


@Controller
@RequestMapping("/api/attachment")
public class AttachmentController {

@Autowired
private AttachmentService attachmentService;

@GetMapping("/{groupChatId}/{messageId}/{attachmentId}")
@RateLimitAPI(Token.BIG_TOKEN)
public ResponseEntity<byte[]> getAttachment(@GetUser User user,
@PathVariable("groupChatId") Long groupChatId,
@PathVariable("messageId") Long messageId,
@PathVariable("attachmentId") Long attachmentId) {
byte[] bytes = attachmentService.getAttachmentBytes(user, groupChatId, messageId, attachmentId);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofDays(30)))
.contentType(MediaType.parseMediaType(attachmentService.getAttachmentType(attachmentId, groupChatId).getValue()))
.body(bytes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class MessageController {
@RateLimitWebSocket(Token.DEFAULT_TOKEN)
public Message sendMessage(@DestinationVariable(value = "id") Long id, Principal principal, @Payload NewMessageForm newMessageForm) {
User user = userService.getUserFromWebSocket(principal);
return messageService.sendMessage(id, newMessageForm.getContent(), user);
return messageService.sendMessage(id, newMessageForm, user);
}

@GetMapping(path = "/{id}/get", produces = MediaType.APPLICATION_JSON_VALUE)
Expand Down
126 changes: 126 additions & 0 deletions backend/src/main/java/com/jasonpyau/chatapp/entity/Attachment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.jasonpyau.chatapp.entity;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.http.MediaType;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonValue;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "attachment", indexes = {
@Index(name = "created_at_ind", columnList = "created_at")
})
public class Attachment {

public static final int SIZE_LIMIT_IN_MB = 10;
public static final int FILE_NAME_LENGTH_LIMIT = 100;
public static final String INVALID_ATTACHMENT_TYPE = "'attachmentType' should be one of the following: "+validAttachmentTypes().toString();
public static final String ATTACHMENT_EXCEEDS_SIZE_LIMIT = "The attachment can be at most "+SIZE_LIMIT_IN_MB+"MB.";
public static final String INVALID_FILE_NAME = "The attachment has too long of a file name. It can be at most "+FILE_NAME_LENGTH_LIMIT+" characters long";

public enum AttachmentType {
IMAGE_JPEG_VALUE(MediaType.IMAGE_JPEG_VALUE), IMAGE_PNG_VALUE(MediaType.IMAGE_PNG_VALUE), IMAGE_GIF_VALUE(MediaType.IMAGE_GIF_VALUE);

@Getter
@JsonValue
private final String value;

AttachmentType(String value) {
this.value = value;
}

public static AttachmentType fromValue(String value) {
switch (value) {
case MediaType.IMAGE_JPEG_VALUE:
return IMAGE_JPEG_VALUE;
case MediaType.IMAGE_PNG_VALUE:
return IMAGE_PNG_VALUE;
case MediaType.IMAGE_GIF_VALUE:
return IMAGE_GIF_VALUE;
default:
return null;
}
}
}

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "id")
private Long id;

@Column(name = "created_at")
private Long createdAt;

@Column(name = "attachment_type")
@Enumerated(EnumType.STRING)
private AttachmentType attachmentType;

// "/api/attachment/{groupChat.id}/{message.id}/{id}"
@Column(name = "url")
private String url;

// If an attachment already exists with the same sender AND file type AND file hash,
// no need to create a new AWS S3 Object.
// "attachment/{sender.id}/{attachmentType}/{fileHash}"
@Column(name = "aws_S3_Key")
@JsonIgnore
private String awsS3Key;

@Column(name = "file_name")
private String fileName;

@Column(name = "file_hash")
private String fileHash;

@Column(name = "file_byte_size")
private Integer fileByteSize;

@Column(name = "file_compress_byte_size")
private Integer fileCompressByteSize;

@JoinColumn(name = "sender")
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
private User sender;

@JoinColumn(name = "group_chat")
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
private GroupChat groupChat;

@JoinColumn(name = "message")
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
private Message message;

public static Set<String> validAttachmentTypes() {
return List.of(AttachmentType.values()).stream().map(AttachmentType::getValue).collect(Collectors.toSet());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ public class GroupChat {
@JsonIgnore
private final Set<Message> messages = new HashSet<>();

@Column(name = "attachments")
@OneToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE}, mappedBy = "groupChat")
@JsonIgnore
private final Set<Attachment> attachments = new HashSet<>();

@Column(name = "last_message_at")
private Long lastMessageAt;

Expand Down
16 changes: 12 additions & 4 deletions backend/src/main/java/com/jasonpyau/chatapp/entity/Message.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.jasonpyau.chatapp.entity;

import java.util.HashSet;
import java.util.Set;

import com.fasterxml.jackson.annotation.JsonIgnore;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand All @@ -13,8 +17,8 @@
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -32,10 +36,10 @@
})
public class Message {

public static final String INVALID_CONTENT = "'content' should be between 1-1000 characters.";
public static final String INVALID_CONTENT = "'content' should be at most 1000 characters.";

public enum MessageType {
USER_JOIN, USER_LEAVE, USER_CHAT, USER_RENAME
USER_JOIN, USER_LEAVE, USER_CHAT, USER_RENAME, HIDDEN
}

@Id
Expand All @@ -44,7 +48,6 @@ public enum MessageType {
private Long id;

@Column(name = "content", columnDefinition = "varchar(1000)")
@Size(min = 1, max = 1000, message = INVALID_CONTENT)
private String content;

@Column(name = "created_at")
Expand All @@ -65,4 +68,9 @@ public enum MessageType {
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
private GroupChat groupChat;

@Column(name = "attachments")
@OneToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE}, mappedBy = "message")
private final Set<Attachment> attachments = new HashSet<>();

}
5 changes: 5 additions & 0 deletions backend/src/main/java/com/jasonpyau/chatapp/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ public enum Role {
@JsonIgnore
private final Set<Message> messages = new HashSet<>();

@Column(name = "attachments")
@OneToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE}, mappedBy = "sender")
@JsonIgnore
private final Set<Attachment> attachments = new HashSet<>();

@Override
public boolean equals(Object o) {
if (this == o) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import java.util.Set;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Builder
public class NewGroupChatForm {

private String name;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
package com.jasonpyau.chatapp.form;

import org.springframework.util.StringUtils;

import com.jasonpyau.chatapp.entity.Attachment;
import com.jasonpyau.chatapp.entity.Message;

import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class NewMessageForm {

public static final String MISSING_CONTENT_AND_ATTACHMENT = "If 'content' is blank, then 'file' and 'fileName' must not be blank.";

@Size(max = 1000, message = Message.INVALID_CONTENT)
private String content;

private String file;

@Size(max = Attachment.FILE_NAME_LENGTH_LIMIT, message = Attachment.INVALID_FILE_NAME)
private String fileName;

@AssertTrue(message = MISSING_CONTENT_AND_ATTACHMENT)
public boolean hasContentOrFile() {
return (StringUtils.hasText(content) || (StringUtils.hasText(file) && StringUtils.hasText(fileName)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.jasonpyau.chatapp.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.jasonpyau.chatapp.entity.Attachment;

@Repository
public interface AttachmentRepository extends JpaRepository<Attachment, Long> {

@Query(value = "SELECT * from attachment a WHERE a.id = :id AND a.group_chat = :groupChatId", nativeQuery = true)
public Optional<Attachment> findByIdInGroupChat(@Param("id") Long id, @Param("groupChatId") Long groupChatId);
}
Loading

0 comments on commit 67166f7

Please sign in to comment.