Add: complete MPaste service rewritten in Spring

This commit is contained in:
Jose
2026-02-24 02:39:01 +01:00
parent 7c70af6a8c
commit f579af11a7
16 changed files with 676 additions and 5 deletions

View File

@@ -31,10 +31,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
@@ -58,6 +54,12 @@
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>net.miarma</groupId>
<artifactId>backlib</artifactId>
<version>1.1.1</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@@ -0,0 +1,15 @@
package net.miarma.backend.mpaste;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {
"net.miarma.backend.mpaste",
"net.miarma.backlib"
})
public class MPasteApplication {
public static void main(String[] args) {
SpringApplication.run(MPasteApplication.class, args);
}
}

View File

@@ -0,0 +1,17 @@
package net.miarma.backend.mpaste.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class NoSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
}

View File

@@ -0,0 +1,9 @@
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,69 @@
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;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/pastes")
public class PasteController {
private PasteService pasteService;
public PasteController(PasteService pasteService) {
this.pasteService = pasteService;
}
@GetMapping
public ResponseEntity<List<PasteDto.Response>> getAll() {
return ResponseEntity.ok(
pasteService.getAll().stream()
.map(PasteMapper::toResponse)
.toList()
);
}
@GetMapping("/by-id/{paste_id}")
public ResponseEntity<PasteDto.Response> getById(@PathVariable("paste_id") UUID pasteId) {
return ResponseEntity.ok(
PasteMapper.toResponse(pasteService.getById(pasteId))
);
}
@GetMapping("/{paste_key}")
public ResponseEntity<PasteDto.Response> getByKey(@PathVariable("paste_key") String pasteKey, @RequestBody PastePassword dto) {
return ResponseEntity.ok(
PasteMapper.toResponse(pasteService.getByKey(pasteKey, dto.password()))
);
}
@PostMapping
public ResponseEntity<PasteDto.Response> create(PasteDto.Request request) {
return ResponseEntity.ok(
PasteMapper.toResponse(
pasteService.create(
PasteMapper.toEntity(request)
)
)
);
}
@PutMapping("/{paste_id}")
public void update() {
throw new UnsupportedOperationException("Pastes cannot be updated");
}
@DeleteMapping("/{paste_id}")
public ResponseEntity<PasteDto.Response> delete(@PathVariable("paste_id") UUID pasteId) {
return ResponseEntity.ok(
PasteMapper.toResponse(
pasteService.delete(pasteId)
)
);
}
}

View File

@@ -0,0 +1,157 @@
package net.miarma.backend.mpaste.dto;
import java.time.Instant;
import java.util.UUID;
public class PasteDto {
private PasteDto() { }
public static class Request {
private String title;
private String content;
private String syntax;
private Boolean burnAfter;
private Boolean isPrivate;
private String password;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getSyntax() {
return syntax;
}
public void setSyntax(String syntax) {
this.syntax = syntax;
}
public Boolean getBurnAfter() {
return burnAfter;
}
public void setBurnAfter(Boolean burnAfter) {
this.burnAfter = burnAfter;
}
public Boolean getIsPrivate() {
return isPrivate;
}
public void setIsPrivate(Boolean isPrivate) {
this.isPrivate = isPrivate;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
public static class Response {
private UUID pasteId;
private String pasteKey;
private String title;
private String content;
private String syntax;
private Integer views;
private Boolean burnAfter;
private Boolean isPrivate;
private Instant createdAt;
public UUID getPasteId() {
return pasteId;
}
public void setPasteId(UUID pasteId) {
this.pasteId = pasteId;
}
public String getPasteKey() {
return pasteKey;
}
public void setPasteKey(String pasteKey) {
this.pasteKey = pasteKey;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getSyntax() {
return syntax;
}
public void setSyntax(String syntax) {
this.syntax = syntax;
}
public Integer getViews() {
return views;
}
public void setViews(Integer views) {
this.views = views;
}
public Boolean getBurnAfter() {
return burnAfter;
}
public void setBurnAfter(Boolean burnAfter) {
this.burnAfter = burnAfter;
}
public Boolean getIsPrivate() {
return isPrivate;
}
public void setIsPrivate(Boolean isPrivate) {
this.isPrivate = isPrivate;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
}

View File

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

View File

@@ -0,0 +1,51 @@
package net.miarma.backend.mpaste.mapper;
import net.miarma.backend.mpaste.dto.PasteDto;
import net.miarma.backend.mpaste.model.Paste;
import java.time.Instant;
public final class PasteMapper {
private PasteMapper() { }
public static Paste toEntity(PasteDto.Request request) {
Paste paste = new Paste();
paste.setTitle(request.getTitle());
paste.setContent(request.getContent());
paste.setSyntax(request.getSyntax());
paste.setBurnAfter(Boolean.TRUE.equals(request.getBurnAfter()));
paste.setPrivate(Boolean.TRUE.equals(request.getIsPrivate()));
paste.setPassword(request.getPassword());
paste.setViews(0);
paste.setCreatedAt(Instant.now());
return paste;
}
public static PasteDto.Response toResponse(Paste paste) {
PasteDto.Response response = new PasteDto.Response();
response.setPasteId(paste.getPasteId());
response.setPasteKey(paste.getPasteKey());
response.setTitle(paste.getTitle());
response.setContent(paste.getContent());
response.setSyntax(paste.getSyntax());
response.setViews(paste.getViews());
response.setBurnAfter(paste.isBurnAfter());
response.setIsPrivate(paste.isPrivate());
response.setCreatedAt(paste.getCreatedAt());
return response;
}
}

View File

@@ -0,0 +1,158 @@
package net.miarma.backend.mpaste.model;
import jakarta.persistence.*;
import net.miarma.backlib.util.UuidUtil;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "mpaste_pastes")
public class Paste {
@Id
@Column(name = "paste_id", columnDefinition = "BINARY(16)")
private byte[] pasteIdBin;
@Transient
private UUID pasteId;
@Column(name = "owner_id", columnDefinition = "BINARY(16)")
private byte[] ownerIdBin;
@Transient
private UUID ownerId;
@Column(name = "paste_key", updatable = false, insertable = false, unique = true, nullable = false)
private String pasteKey;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
private String syntax;
private Integer views;
@Column(name = "burn_after")
private Boolean burnAfter;
@Column(name = "is_private")
private Boolean isPrivate;
private String password;
@Column(name = "created_at")
private Instant createdAt;
@PrePersist
@PreUpdate
private void prePersist() {
if (pasteId != null) {
pasteIdBin = UuidUtil.uuidToBin(pasteId);
}
if (ownerId != null) {
ownerIdBin = UuidUtil.uuidToBin(ownerId);
}
}
@PostLoad
private void postLoad() {
if (pasteIdBin != null) {
pasteId = UuidUtil.binToUUID(pasteIdBin);
}
if (ownerIdBin != null) {
ownerId = UuidUtil.binToUUID(ownerIdBin);
}
}
public UUID getPasteId() {
return pasteId;
}
public void setPasteId(UUID pasteId) {
this.pasteId = pasteId;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public String getPasteKey() {
return pasteKey;
}
public void setPasteKey(String pasteKey) {
this.pasteKey = pasteKey;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getSyntax() {
return syntax;
}
public void setSyntax(String syntax) {
this.syntax = syntax;
}
public Integer getViews() {
return views;
}
public void setViews(Integer views) {
this.views = views;
}
public Boolean isBurnAfter() {
return burnAfter;
}
public void setBurnAfter(Boolean burnAfter) {
this.burnAfter = burnAfter;
}
public Boolean isPrivate() {
return isPrivate;
}
public void setPrivate(Boolean aPrivate) {
isPrivate = aPrivate;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,10 @@
package net.miarma.backend.mpaste.repository;
import net.miarma.backend.mpaste.model.Paste;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PasteRepository extends JpaRepository<Paste, byte[]> {
Optional<Paste> findByPasteKey(String pasteKey);
}

View File

@@ -0,0 +1,81 @@
package net.miarma.backend.mpaste.service;
import jakarta.transaction.Transactional;
import net.miarma.backend.mpaste.model.Paste;
import net.miarma.backend.mpaste.repository.PasteRepository;
import net.miarma.backend.mpaste.validation.PasteValidator;
import net.miarma.backlib.exception.ForbiddenException;
import net.miarma.backlib.exception.NotFoundException;
import net.miarma.backlib.util.UuidUtil;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Service
@Transactional
public class PasteService {
private final PasteRepository pasteRepository;
private final PasswordEncoder passwordEncoder;
private final TaskScheduler taskScheduler;
public PasteService(PasteRepository pasteRepository, PasswordEncoder passwordEncoder, TaskScheduler taskScheduler) {
this.pasteRepository = pasteRepository;
this.passwordEncoder = passwordEncoder;
this.taskScheduler = taskScheduler;
}
public List<Paste> getAll() {
return pasteRepository.findAll().stream()
.filter(p -> !Boolean.TRUE.equals(p.isPrivate()))
.toList();
}
public Paste getById(UUID pasteId) {
byte[] idBytes = UuidUtil.uuidToBin(pasteId);
return pasteRepository.findById(idBytes)
.orElseThrow(() -> new NotFoundException("Paste not found"));
}
public Paste getByKey(String pasteKey, String password) {
Paste paste = pasteRepository.findByPasteKey(pasteKey)
.orElseThrow(() -> new NotFoundException("Paste not found"));
if(Boolean.TRUE.equals(paste.isPrivate())) {
if(password == null || passwordEncoder.matches(password, paste.getPassword())) {
throw new ForbiddenException("Incorrect password");
}
}
if(Boolean.TRUE.equals(paste.isBurnAfter())) {
taskScheduler.schedule(
() -> pasteRepository.delete(paste),
Instant.now().plusSeconds(5)
);
}
return paste;
}
public Paste create(Paste paste) {
PasteValidator.validate(paste);
return pasteRepository.save(paste);
}
public Paste update(UUID pasteId, Paste changes) {
throw new UnsupportedOperationException("Pastes cannot be updated");
}
public Paste delete(UUID pasteId) {
byte[] idBytes = UuidUtil.uuidToBin(pasteId);
if(!pasteRepository.existsById(idBytes)) {
throw new NotFoundException("Paste not found");
}
Paste paste = getById(pasteId);
pasteRepository.deleteById(idBytes);
return paste;
}
}

View File

@@ -0,0 +1,17 @@
package net.miarma.backend.mpaste.util;
import java.security.SecureRandom;
public class PasteKeyGenerator {
private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final SecureRandom RANDOM = new SecureRandom();
public static String generate(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
int index = RANDOM.nextInt(ALPHABET.length());
sb.append(ALPHABET.charAt(index));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,26 @@
package net.miarma.backend.mpaste.validation;
import net.miarma.backend.mpaste.model.Paste;
import net.miarma.backlib.exception.ValidationException;
public class PasteValidator {
public static void validate(Paste paste) {
if (paste.getTitle() == null || paste.getTitle().trim().isEmpty()) {
throw new ValidationException("title", "The title cannot be empty");
}
if (paste.getContent() == null || paste.getContent().trim().isEmpty()) {
throw new ValidationException("content", "The content cannot be empty");
}
if (Boolean.TRUE.equals(paste.isPrivate())) {
if (paste.getPassword() == null || paste.getPassword().trim().isEmpty()) {
throw new ValidationException("password", "Private pastes require password");
}
}
if (paste.getTitle() != null && paste.getTitle().length() > 128) {
throw new ValidationException("title", "Title too long (128 characters max.)");
}
}
}

View File

@@ -0,0 +1,16 @@
server:
port: 8081
servlet:
context-path: /v2/mpaste
spring:
datasource:
url: jdbc:mariadb://localhost:3306/miarma_v2
username: admin
password: ${DB_PASS}
driver-class-name: org.mariadb.jdbc.Driver
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE

View File

@@ -0,0 +1,15 @@
server:
port: 8081
servlet:
context-path: /v2/mpaste
spring:
datasource:
url: jdbc:mariadb://mariadb:3306/miarma_v2
username: ${DB_USER}
password: ${DB_PASS}
driver-class-name: org.mariadb.jdbc.Driver
logging:
level:
org.hibernate.SQL: WARN

View File

@@ -0,0 +1,25 @@
spring:
application:
name: mpaste-service
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
jdbc:
time_zone: UTC
jackson:
default-property-inclusion: non_null
time-zone: Europe/Madrid
jwt:
expiration-ms: 3600000
management:
endpoints:
web:
exposure:
include: health,info