add: experimental real time websocket-based pastes

This commit is contained in:
2026-03-17 02:27:33 +01:00
parent cad49a86ac
commit e9682f095b
13 changed files with 191 additions and 36 deletions

View File

@@ -31,6 +31,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
@@ -60,6 +68,14 @@
<version>1.1.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,27 @@
package net.miarma.backend.mpaste.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
RedisSerializer<Object> jsonSerializer = RedisSerializer.json();
template.setKeySerializer(RedisSerializer.string());
template.setValueSerializer(jsonSerializer);
template.setHashKeySerializer(RedisSerializer.string());
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}

View File

@@ -1,9 +0,0 @@
package net.miarma.backend.mpaste.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@Configuration
public class SchedulingConfig {
}

View File

@@ -0,0 +1,24 @@
package net.miarma.backend.mpaste.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}

View File

@@ -1,7 +1,6 @@
package net.miarma.backend.mpaste.controller;
import net.miarma.backend.mpaste.dto.PasteDto;
import net.miarma.backend.mpaste.dto.PastePassword;
import net.miarma.backend.mpaste.mapper.PasteMapper;
import net.miarma.backend.mpaste.service.PasteService;
import org.springframework.http.ResponseEntity;
@@ -35,7 +34,7 @@ public class PasteController {
);
}
@GetMapping("/{paste_key}")
@GetMapping("/s/{paste_key}")
public ResponseEntity<PasteDto.Response> getByKey(
@PathVariable("paste_key") String pasteKey,
@RequestHeader(value = "X-Paste-Password", required = false) String password
@@ -63,10 +62,6 @@ public class PasteController {
@DeleteMapping("/{paste_id}")
public ResponseEntity<PasteDto.Response> delete(@PathVariable("paste_id") UUID pasteId) {
return ResponseEntity.ok(
PasteMapper.toResponse(
pasteService.delete(pasteId)
)
);
throw new UnsupportedOperationException("Pastes cannot be deleted manually");
}
}

View File

@@ -0,0 +1,42 @@
package net.miarma.backend.mpaste.controller;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@Controller
public class RTPasteController {
private final RedisTemplate<String, Object> template;
private static final String REDIS_PREFIX = "rt_paste:";
public RTPasteController(RedisTemplate<String, Object> template) {
this.template = template;
}
@MessageMapping("/edit/{key}")
@SendTo("/topic/session/{key}")
public Map<String, Object> handleUpdate(@DestinationVariable("key") String key, Map<String, Object> payload) {
template.opsForValue().set(REDIS_PREFIX + key, payload, 24, TimeUnit.HOURS);
return payload;
}
@MessageMapping("/join/{key}")
@SendTo("/topic/session/{key}")
public Map<String, Object> handleJoin(@DestinationVariable("key") String key) {
Object data = template.opsForValue().get(REDIS_PREFIX + key);
if (data instanceof Map) {
return (Map<String, Object>) data;
}
return Map.of("content", "", "syntax", "plaintext", "title", "");
}
}

View File

@@ -9,14 +9,24 @@ public class PasteDto {
public static class Request {
private String pasteKey;
private String title;
private String content;
private String syntax;
private Boolean burnAfter;
private Boolean isPrivate;
private Boolean isRt;
private String password;
public String getPasteKey() {
return pasteKey;
}
public void setPasteKey(String pasteKey) {
this.pasteKey = pasteKey;
}
public String getTitle() {
return title;
}
@@ -41,7 +51,7 @@ public class PasteDto {
this.syntax = syntax;
}
public Boolean getBurnAfter() {
public Boolean isBurnAfter() {
return burnAfter;
}
@@ -49,7 +59,7 @@ public class PasteDto {
this.burnAfter = burnAfter;
}
public Boolean getIsPrivate() {
public Boolean isPrivate() {
return isPrivate;
}
@@ -57,6 +67,14 @@ public class PasteDto {
this.isPrivate = isPrivate;
}
public Boolean isRt() {
return isRt;
}
public void setRt(Boolean rt) {
isRt = rt;
}
public String getPassword() {
return password;
}
@@ -79,6 +97,7 @@ public class PasteDto {
private Boolean burnAfter;
private Boolean isPrivate;
private Boolean isRt;
private Instant createdAt;
@@ -130,7 +149,7 @@ public class PasteDto {
this.views = views;
}
public Boolean getBurnAfter() {
public Boolean isBurnAfter() {
return burnAfter;
}
@@ -138,14 +157,22 @@ public class PasteDto {
this.burnAfter = burnAfter;
}
public Boolean getIsPrivate() {
public Boolean isPrivate() {
return isPrivate;
}
public void setIsPrivate(Boolean isPrivate) {
public void setPrivate(Boolean isPrivate) {
this.isPrivate = isPrivate;
}
public Boolean isRt() {
return isRt;
}
public void setRt(Boolean rt) {
isRt = rt;
}
public Instant getCreatedAt() {
return createdAt;
}

View File

@@ -1,3 +0,0 @@
package net.miarma.backend.mpaste.dto;
public record PastePassword(String password) {}

View File

@@ -16,9 +16,11 @@ public final class PasteMapper {
paste.setTitle(request.getTitle());
paste.setContent(request.getContent());
paste.setSyntax(request.getSyntax());
paste.setPasteKey(request.getPasteKey());
paste.setBurnAfter(Boolean.TRUE.equals(request.getBurnAfter()));
paste.setPrivate(Boolean.TRUE.equals(request.getIsPrivate()));
paste.setBurnAfter(Boolean.TRUE.equals(request.isBurnAfter()));
paste.setPrivate(Boolean.TRUE.equals(request.isPrivate()));
paste.setRt(Boolean.TRUE.equals(request.isRt()));
paste.setPassword(request.getPassword());
@@ -42,7 +44,8 @@ public final class PasteMapper {
response.setViews(paste.getViews());
response.setBurnAfter(paste.isBurnAfter());
response.setIsPrivate(paste.isPrivate());
response.setPrivate(paste.isPrivate());
response.setRt(paste.isRt());
response.setCreatedAt(paste.getCreatedAt());

View File

@@ -40,6 +40,9 @@ public class Paste {
@Column(name = "is_private")
private Boolean isPrivate;
@Column(name = "is_rt")
private Boolean isRt;
private String password;
@Column(name = "created_at")
@@ -136,10 +139,14 @@ public class Paste {
return isPrivate;
}
public void setPrivate(Boolean aPrivate) {
isPrivate = aPrivate;
public void setPrivate(Boolean isPrivate) {
this.isPrivate = isPrivate;
}
public Boolean isRt() { return isRt; }
public void setRt(Boolean rt) { isRt = rt; }
public String getPassword() {
return password;
}

View File

@@ -32,6 +32,7 @@ public class PasteService {
public List<Paste> getAll() {
return pasteRepository.findAll().stream()
.filter(p -> !Boolean.TRUE.equals(p.isPrivate()))
.filter(p -> !Boolean.TRUE.equals(p.isRt()))
.toList();
}
@@ -64,14 +65,26 @@ public class PasteService {
public Paste create(Paste paste) {
PasteValidator.validate(paste);
if (Boolean.TRUE.equals(paste.isPrivate()) && paste.getPassword() != null) {
String encodedPassword = passwordEncoder.encode(paste.getPassword());
paste.setPassword(encodedPassword);
}
return pasteRepository.findByPasteKey(paste.getPasteKey())
.map(existing -> {
existing.setContent(paste.getContent());
existing.setSyntax(paste.getSyntax());
existing.setTitle(paste.getTitle());
return pasteRepository.save(existing);
})
.orElseGet(() -> {
if (Boolean.TRUE.equals(paste.isPrivate()) && paste.getPassword() != null) {
paste.setPassword(passwordEncoder.encode(paste.getPassword()));
}
paste.setPasteId(UUID.randomUUID());
paste.setPasteKey(PasteKeyGenerator.generate(6));
return pasteRepository.save(paste);
paste.setPasteId(UUID.randomUUID());
if (paste.getPasteKey() == null || paste.getPasteKey().isEmpty()) {
paste.setPasteKey(PasteKeyGenerator.generate(6));
}
return pasteRepository.save(paste);
});
}
public Paste update(UUID pasteId, Paste changes) {

View File

@@ -5,6 +5,14 @@ import net.miarma.backlib.exception.ValidationException;
public class PasteValidator {
public static void validate(Paste paste) {
if (Boolean.TRUE.equals(paste.isRt())) {
if (paste.getTitle() == null || paste.getTitle().trim().isEmpty()) {
String uuidStr = paste.getPasteId().toString();
String shortId = uuidStr.substring(0, 8);
paste.setTitle("Sesión: " + shortId);
}
}
if (paste.getTitle() == null || paste.getTitle().trim().isEmpty()) {
throw new ValidationException("title", "The title cannot be empty");
}

View File

@@ -15,6 +15,11 @@ spring:
default-property-inclusion: non_null
time-zone: Europe/Madrid
data:
redis:
host: localhost
port: 6379
jwt:
expiration-ms: 3600000