Add Server endpoints + shoebill

This commit is contained in:
2026-04-19 19:25:27 -04:00
parent b99a6c50b0
commit d5173e162f
27 changed files with 263 additions and 48 deletions

View File

@@ -4,9 +4,9 @@ plugins {
id 'io.spring.dependency-management' version '1.1.7' id 'io.spring.dependency-management' version '1.1.7'
} }
group = 'net.kpuig.concord' group = 'net.kpuig.shoebill'
version = '0.0.1-SNAPSHOT' version = '0.0.1-SNAPSHOT'
description = 'Concord REST Server' description = 'Shoebill REST Server'
java { java {
toolchain { toolchain {
@@ -30,6 +30,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-h2console' implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-autoconfigure-processor'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'
//implementation 'org.springframework.boot:spring-boot-starter-security' //implementation 'org.springframework.boot:spring-boot-starter-security'
//implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server' //implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'

View File

@@ -1,8 +1,12 @@
package net.kpuig.concord.backend; package net.kpuig.shoebill.backend;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import net.kpuig.shoebill.backend.config.ShoebillConfiguration;
@EnableConfigurationProperties(ShoebillConfiguration.class)
@SpringBootApplication @SpringBootApplication
public class BackendApplication { public class BackendApplication {

View File

@@ -0,0 +1,14 @@
package net.kpuig.shoebill.backend.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(prefix = "shoebill")
@Validated
public record ShoebillConfiguration (
@DefaultValue("false")
boolean relativePicturesOnly
) {
}

View File

@@ -0,0 +1,67 @@
package net.kpuig.shoebill.backend.controllers;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Valid;
import net.kpuig.shoebill.backend.datamodels.server.GetAllServersResponse;
import net.kpuig.shoebill.backend.datamodels.server.GetServerResponse;
import net.kpuig.shoebill.backend.datamodels.server.PostServerRequest;
import net.kpuig.shoebill.backend.datamodels.server.PostServerResponse;
import net.kpuig.shoebill.backend.services.ServerService;
import net.kpuig.shoebill.backend.services.exceptions.BadRequestException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@RestController
@RequestMapping("/api/server")
public class ServerController {
@Autowired private ServerService serverService;
@Operation(summary = "Get all servers", description = "Retrieves a list of all servers")
@ApiResponse(responseCode = "200", description = "Servers retrieved")
@GetMapping("/")
public ResponseEntity<GetAllServersResponse> getAllServers() {
return ResponseEntity.ok(serverService.getAllServers());
}
@Operation(summary = "Get server by ID", description = "Retrieves a server by its ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Server retrieved"),
@ApiResponse(responseCode = "404", description = "Server not found")
})
@GetMapping("")
public ResponseEntity<GetServerResponse> getServerById(@RequestParam(name = "id", required = false) Long id) {
try {
return ResponseEntity.ok(serverService.getServerById(id));
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
@Operation(summary = "Create a new server", description = "Creates a new server with the provided name and image")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Server created"),
@ApiResponse(responseCode = "400", description = "Invalid request")
})
@PostMapping("/")
public ResponseEntity<PostServerResponse> postMethodName(@Valid @RequestBody PostServerRequest request) {
try {
var response = serverService.createServer(request);
return ResponseEntity.ok(response);
} catch (BadRequestException e) {
return ResponseEntity.badRequest().build();
}
}
}

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.controllers; package net.kpuig.shoebill.backend.controllers;
import java.net.URI; import java.net.URI;
@@ -17,18 +17,15 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import net.kpuig.concord.backend.datamodels.userprofile.GetAllUserProfilesResponse; import net.kpuig.shoebill.backend.datamodels.userprofile.GetAllUserProfilesResponse;
import net.kpuig.concord.backend.datamodels.userprofile.GetUserProfileResponse; import net.kpuig.shoebill.backend.datamodels.userprofile.GetUserProfileResponse;
import net.kpuig.concord.backend.datamodels.userprofile.PostUserProfileRequest; import net.kpuig.shoebill.backend.datamodels.userprofile.PostUserProfileRequest;
import net.kpuig.concord.backend.datamodels.userprofile.PostUserProfileResponse; import net.kpuig.shoebill.backend.datamodels.userprofile.PostUserProfileResponse;
import net.kpuig.concord.backend.services.UserProfileService; import net.kpuig.shoebill.backend.services.UserProfileService;
import net.kpuig.concord.backend.services.exceptions.BadRequestException; import net.kpuig.shoebill.backend.services.exceptions.BadRequestException;
@RestController @RestController
@RequestMapping("/user-profile") @RequestMapping("/api/user-profile")
public class UserProfileController { public class UserProfileController {
@Autowired @Autowired
private UserProfileService userProfileService; private UserProfileService userProfileService;

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.datamodels.channel; package net.kpuig.shoebill.backend.datamodels.channel;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
@@ -6,7 +6,7 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import lombok.Data; import lombok.Data;
import net.kpuig.concord.backend.datamodels.server.Server; import net.kpuig.shoebill.backend.datamodels.server.Server;
@Data @Data
@Entity(name = "channel") @Entity(name = "channel")

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.datamodels.channel; package net.kpuig.shoebill.backend.datamodels.channel;
public enum ChannelType { public enum ChannelType {
TEXT_CHANNEL, TEXT_CHANNEL,

View File

@@ -1,13 +1,14 @@
package net.kpuig.concord.backend.datamodels.message; package net.kpuig.shoebill.backend.datamodels.message;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import lombok.Data; import lombok.Data;
import net.kpuig.concord.backend.datamodels.channel.Channel; import net.kpuig.shoebill.backend.datamodels.channel.Channel;
import net.kpuig.concord.backend.datamodels.userprofile.UserProfile; import net.kpuig.shoebill.backend.datamodels.userprofile.UserProfile;
@Data @Data
@Entity(name = "message") @Entity(name = "message")
@@ -20,6 +21,9 @@ public class Message {
private Long timestamp; private Long timestamp;
@OneToOne
private Message replyTo;
@ManyToOne @ManyToOne
private UserProfile userProfile; private UserProfile userProfile;

View File

@@ -0,0 +1,10 @@
package net.kpuig.shoebill.backend.datamodels.server;
import java.util.List;
import lombok.Data;
@Data
public class GetAllServersResponse {
private List<GetServerResponse> servers;
}

View File

@@ -0,0 +1,12 @@
package net.kpuig.shoebill.backend.datamodels.server;
import java.net.URI;
import lombok.Data;
@Data
public class GetServerResponse {
private Long id;
private String name;
private URI image;
}

View File

@@ -0,0 +1,14 @@
package net.kpuig.shoebill.backend.datamodels.server;
import java.net.URI;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class PostServerRequest {
@NotBlank
private String name;
private URI image;
}

View File

@@ -0,0 +1,12 @@
package net.kpuig.shoebill.backend.datamodels.server;
import java.net.URI;
import lombok.Data;
@Data
public class PostServerResponse {
private Long id;
private String name;
private URI image;
}

View File

@@ -1,6 +1,6 @@
package net.kpuig.concord.backend.datamodels.server; package net.kpuig.shoebill.backend.datamodels.server;
import java.net.URL; import java.net.URI;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
@@ -17,5 +17,5 @@ public class Server {
private String name; private String name;
private URL image; private URI image;
} }

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.datamodels.userprofile; package net.kpuig.shoebill.backend.datamodels.userprofile;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.datamodels.userprofile; package net.kpuig.shoebill.backend.datamodels.userprofile;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.datamodels.userprofile; package net.kpuig.shoebill.backend.datamodels.userprofile;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.datamodels.userprofile; package net.kpuig.shoebill.backend.datamodels.userprofile;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.datamodels.userprofile; package net.kpuig.shoebill.backend.datamodels.userprofile;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;

View File

@@ -1,9 +1,9 @@
package net.kpuig.concord.backend.repositories; package net.kpuig.shoebill.backend.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import net.kpuig.concord.backend.datamodels.channel.Channel; import net.kpuig.shoebill.backend.datamodels.channel.Channel;
@Repository @Repository
public interface ChannelRepository extends JpaRepository<Channel, Long> { public interface ChannelRepository extends JpaRepository<Channel, Long> {

View File

@@ -1,9 +1,9 @@
package net.kpuig.concord.backend.repositories; package net.kpuig.shoebill.backend.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import net.kpuig.concord.backend.datamodels.message.Message; import net.kpuig.shoebill.backend.datamodels.message.Message;
@Repository @Repository
public interface MessageRepository extends JpaRepository<Message, Long> { public interface MessageRepository extends JpaRepository<Message, Long> {

View File

@@ -1,9 +1,9 @@
package net.kpuig.concord.backend.repositories; package net.kpuig.shoebill.backend.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import net.kpuig.concord.backend.datamodels.server.Server; import net.kpuig.shoebill.backend.datamodels.server.Server;
@Repository @Repository
public interface ServerRepository extends JpaRepository<Server, Long> { public interface ServerRepository extends JpaRepository<Server, Long> {

View File

@@ -1,11 +1,11 @@
package net.kpuig.concord.backend.repositories; package net.kpuig.shoebill.backend.repositories;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import net.kpuig.concord.backend.datamodels.userprofile.UserProfile; import net.kpuig.shoebill.backend.datamodels.userprofile.UserProfile;
@Repository @Repository
public interface UserProfileRepository extends JpaRepository<UserProfile, Long> { public interface UserProfileRepository extends JpaRepository<UserProfile, Long> {

View File

@@ -0,0 +1,79 @@
package net.kpuig.shoebill.backend.services;
import java.net.URI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import net.kpuig.shoebill.backend.config.ShoebillConfiguration;
import net.kpuig.shoebill.backend.datamodels.server.GetAllServersResponse;
import net.kpuig.shoebill.backend.datamodels.server.GetServerResponse;
import net.kpuig.shoebill.backend.datamodels.server.PostServerRequest;
import net.kpuig.shoebill.backend.datamodels.server.PostServerResponse;
import net.kpuig.shoebill.backend.datamodels.server.Server;
import net.kpuig.shoebill.backend.repositories.ServerRepository;
import net.kpuig.shoebill.backend.services.exceptions.BadRequestException;
import net.kpuig.shoebill.backend.services.exceptions.NotFoundException;
@Service
public class ServerService {
@Autowired private ShoebillConfiguration shoebillConfiguration;
@Autowired private ServerRepository serverRepository;
public GetAllServersResponse getAllServers() {
GetAllServersResponse response = new GetAllServersResponse();
response.setServers(serverRepository.findAll().stream().map(server -> {
GetServerResponse serverResponse = new GetServerResponse();
serverResponse.setId(server.getId());
serverResponse.setName(server.getName());
serverResponse.setImage(server.getImage());
return serverResponse;
}).toList());
return response;
}
public GetServerResponse getServerById(Long id) {
GetServerResponse response = new GetServerResponse();
serverRepository.findById(id).ifPresentOrElse(server -> {
response.setId(server.getId());
response.setName(server.getName());
response.setImage(server.getImage());
}, () -> {
throw new NotFoundException("Server not found with id: " + id);
});
return response;
}
public PostServerResponse createServer(PostServerRequest request) {
Server server = new Server();
server.setName(request.getName());
server.setImage(request.getImage());
try {
server = serverRepository.save(server);
var uri = new URI(request.getImage().toString());
// Make sure it's either relative with no injections or a complete HTTP/HTTPS URL
if (uri.isAbsolute()) {
if (shoebillConfiguration.relativePicturesOnly()) {
throw new BadRequestException("Only relative image URLs are allowed");
}
if (!uri.getScheme().equals("http") && !uri.getScheme().equals("https")) {
throw new BadRequestException("Image URL must be HTTP or HTTPS");
}
} else {
if (request.getImage().toString().contains("..") || request.getImage().toString().contains("//") || request.getImage().toString().contains("\\")) {
throw new BadRequestException("Relative image URL cannot contain '..', '//', or '\\'");
}
}
} catch (Exception e) {
throw new BadRequestException("Invalid image URL");
}
PostServerResponse response = new PostServerResponse();
response.setId(server.getId());
response.setName(server.getName());
response.setImage(server.getImage());
return response;
}
}

View File

@@ -1,14 +1,14 @@
package net.kpuig.concord.backend.services; package net.kpuig.shoebill.backend.services;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import net.kpuig.concord.backend.datamodels.userprofile.GetAllUserProfilesResponse; import net.kpuig.shoebill.backend.datamodels.userprofile.GetAllUserProfilesResponse;
import net.kpuig.concord.backend.datamodels.userprofile.GetUserProfileResponse; import net.kpuig.shoebill.backend.datamodels.userprofile.GetUserProfileResponse;
import net.kpuig.concord.backend.datamodels.userprofile.PostUserProfileResponse; import net.kpuig.shoebill.backend.datamodels.userprofile.PostUserProfileResponse;
import net.kpuig.concord.backend.datamodels.userprofile.UserProfile; import net.kpuig.shoebill.backend.datamodels.userprofile.UserProfile;
import net.kpuig.concord.backend.repositories.UserProfileRepository; import net.kpuig.shoebill.backend.repositories.UserProfileRepository;
import net.kpuig.concord.backend.services.exceptions.BadRequestException; import net.kpuig.shoebill.backend.services.exceptions.BadRequestException;
@Service @Service
public class UserProfileService { public class UserProfileService {
@@ -32,7 +32,7 @@ public class UserProfileService {
response.setId(userProfile.getId()); response.setId(userProfile.getId());
response.setUsername(userProfile.getUsername()); response.setUsername(userProfile.getUsername());
}, () -> { }, () -> {
throw new net.kpuig.concord.backend.services.exceptions.NotFoundException("User profile not found"); throw new net.kpuig.shoebill.backend.services.exceptions.NotFoundException("User profile not found");
}); });
return response; return response;
} }
@@ -43,7 +43,7 @@ public class UserProfileService {
response.setId(userProfile.getId()); response.setId(userProfile.getId());
response.setUsername(userProfile.getUsername()); response.setUsername(userProfile.getUsername());
}, () -> { }, () -> {
throw new net.kpuig.concord.backend.services.exceptions.NotFoundException("User profile not found"); throw new net.kpuig.shoebill.backend.services.exceptions.NotFoundException("User profile not found");
}); });
return response; return response;
} }

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.services.exceptions; package net.kpuig.shoebill.backend.services.exceptions;
public class BadRequestException extends RuntimeException { public class BadRequestException extends RuntimeException {
public BadRequestException(String message) { public BadRequestException(String message) {

View File

@@ -1,4 +1,4 @@
package net.kpuig.concord.backend.services.exceptions; package net.kpuig.shoebill.backend.services.exceptions;
public class NotFoundException extends RuntimeException { public class NotFoundException extends RuntimeException {
public NotFoundException(String message) { public NotFoundException(String message) {

View File

@@ -13,3 +13,4 @@ springdoc.api-docs.enabled=true
springdoc.api-docs.path=/v3/api-docs springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.enabled=true springdoc.swagger-ui.enabled=true
springdoc.swagger-ui.path=/swag springdoc.swagger-ui.path=/swag
shoebill.relative-pictures-only=true