Added stuff.

This commit is contained in:
Andrew Lalis 2022-05-12 22:10:42 +02:00
parent ecd9549e77
commit 35c13d83bd
20 changed files with 435 additions and 90 deletions

View File

@ -25,6 +25,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId> <artifactId>spring-boot-starter-thymeleaf</artifactId>

View File

@ -1 +1,2 @@
VITE_API_URL=http://localhost:8080/api VITE_API_URL=http://localhost:8080/api
VITE_WS_URL=ws://localhost:8080/api/ws/app

View File

@ -1 +1,2 @@
VITE_API_URL=http://localhost:8080/api VITE_API_URL=http://localhost:8080/api
VITE_WS_URL=ws://localhost:8080/api/ws/app

View File

@ -63,12 +63,6 @@ export default {
components: {AddRailSystem: AddRailSystemModal, ConfirmModal}, components: {AddRailSystem: AddRailSystemModal, ConfirmModal},
setup() { setup() {
const rsStore = useRailSystemsStore(); const rsStore = useRailSystemsStore();
rsStore.$subscribe(mutation => {
const evt = mutation.events;
if (evt.key === "selectedRailSystem" && evt.newValue !== null) {
rsStore.fetchSelectedRailSystemData();
}
});
return { return {
rsStore rsStore
}; };

View File

@ -3,10 +3,20 @@ import { createPinia } from 'pinia';
import App from './App.vue' import App from './App.vue'
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap"; import "bootstrap";
import {useRailSystemsStore} from "./stores/railSystemsStore";
const pinia = createPinia(); const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app.use(pinia); app.use(pinia);
// Configure rail system updates.
const rsStore = useRailSystemsStore();
rsStore.$subscribe(mutation => {
const evt = mutation.events;
if (evt.key === "selectedRailSystem" && evt.newValue !== null) {
rsStore.onSelectedRailSystemChanged();
}
});
app.mount('#app') app.mount('#app')

View File

@ -5,10 +5,17 @@ export const useRailSystemsStore = defineStore('RailSystemsStore', {
state: () => ({ state: () => ({
railSystems: [], railSystems: [],
/** /**
* @type {{segments: [Object], components: [Object], selectedComponent: Object} | null} * @type {{
* segments: [Object],
* components: [Object],
* selectedComponent: Object | null,
* websocket: WebSocket | null
* } | null}
*/ */
selectedRailSystem: null, selectedRailSystem: null,
apiUrl: import.meta.env.VITE_API_URL websocket: null,
apiUrl: import.meta.env.VITE_API_URL,
wsUrl: import.meta.env.VITE_WS_URL
}), }),
actions: { actions: {
refreshRailSystems() { refreshRailSystems() {
@ -65,10 +72,24 @@ export const useRailSystemsStore = defineStore('RailSystemsStore', {
}); });
}); });
}, },
fetchSelectedRailSystemData() { onSelectedRailSystemChanged() {
if (!this.selectedRailSystem) return; if (!this.selectedRailSystem) return;
this.refreshSegments(this.selectedRailSystem); this.refreshSegments(this.selectedRailSystem);
this.refreshAllComponents(this.selectedRailSystem); this.refreshAllComponents(this.selectedRailSystem);
if (this.websocket !== null) {
this.websocket.close();
}
console.log(this.wsUrl);
this.websocket = new WebSocket(this.wsUrl);
this.websocket.onopen = event => {
console.log("Opened websocket connection.");
};
this.websocket.onclose = event => {
console.log("Closed websocket connection.");
};
this.websocket.onmessage = () => {
};
}, },
addSegment(name) { addSegment(name) {
const rs = this.selectedRailSystem; const rs = this.selectedRailSystem;

View File

@ -0,0 +1,24 @@
package nl.andrewl.railsignalapi.live;
import lombok.Getter;
public abstract class ComponentDownlink {
@Getter
private final long id;
public ComponentDownlink(long id) {
this.id = id;
}
public abstract void send(Object msg) throws Exception;
@Override
public boolean equals(Object o) {
return o instanceof ComponentDownlink cd && cd.id == this.id;
}
@Override
public int hashCode() {
return Long.hashCode(id);
}
}

View File

@ -0,0 +1,34 @@
package nl.andrewl.railsignalapi.live;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* A service that manages all the active component downlink connections.
*/
@Service
@RequiredArgsConstructor
public class ComponentDownlinkService {
private final Map<ComponentDownlink, Set<Long>> componentDownlinks = new HashMap<>();
public synchronized void registerDownlink(ComponentDownlink downlink, Set<Long> componentIds) {
componentDownlinks.put(downlink, componentIds);
}
public synchronized void deregisterDownlink(ComponentDownlink downlink) {
componentDownlinks.remove(downlink);
}
public synchronized void deregisterDownlink(long tokenId) {
List<ComponentDownlink> removeSet = componentDownlinks.keySet().stream()
.filter(downlink -> downlink.getId() == tokenId).toList();
for (var downlink : removeSet) {
componentDownlinks.remove(downlink);
}
}
}

View File

@ -0,0 +1,24 @@
package nl.andrewl.railsignalapi.live.tcp_socket;
import nl.andrewl.railsignalapi.live.ComponentDownlink;
import nl.andrewl.railsignalapi.util.JsonUtils;
import java.io.DataOutputStream;
import java.nio.charset.StandardCharsets;
public class TcpDownlink extends ComponentDownlink {
private final DataOutputStream out;
public TcpDownlink(long id, DataOutputStream out) {
super(id);
this.out = out;
}
@Override
public void send(Object msg) throws Exception {
byte[] jsonBytes = JsonUtils.toJson(msg).getBytes(StandardCharsets.UTF_8);
out.writeInt(jsonBytes.length);
out.write(jsonBytes);
out.flush();
}
}

View File

@ -0,0 +1,79 @@
package nl.andrewl.railsignalapi.live.tcp_socket;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentAccessTokenRepository;
import nl.andrewl.railsignalapi.model.ComponentAccessToken;
import nl.andrewl.railsignalapi.util.JsonUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* A plain TCP server socket which can be used to connect to components that
* don't have access to a full websocket client implementation.
*/
@Component
@Slf4j
public class TcpSocketServer {
private final ServerSocket serverSocket;
private final ComponentAccessTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
public TcpSocketServer(ComponentAccessTokenRepository tokenRepository, PasswordEncoder passwordEncoder) throws IOException {
this.tokenRepository = tokenRepository;
this.passwordEncoder = passwordEncoder;
this.serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.bind(new InetSocketAddress("localhost", 8081));
}
@EventListener(ApplicationReadyEvent.class)
public void runServer() {
new Thread(() -> {
log.info("Starting TCP Socket for Component links at " + serverSocket.getInetAddress());
while (!serverSocket.isClosed()) {
try {
Socket socket = serverSocket.accept();
initializeConnection(socket);
} catch (IOException e) {
log.warn("An IOException occurred while waiting to accept a TCP socket connection.", e);
}
}
});
}
@EventListener(ContextClosedEvent.class)
public void closeServer() throws IOException {
serverSocket.close();
}
private void initializeConnection(Socket socket) throws IOException {
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
DataInputStream in = new DataInputStream(socket.getInputStream());
int tokenLength = in.readInt();
String rawToken = new String(in.readNBytes(tokenLength));
if (rawToken.length() < ComponentAccessToken.PREFIX_SIZE) {
byte[] respBytes = JsonUtils.toJson(Map.of("message", "Invalid token")).getBytes(StandardCharsets.UTF_8);
out.writeInt(respBytes.length);
out.write(respBytes);
socket.close();
}
Iterable<ComponentAccessToken> tokens = tokenRepository.findAllByTokenPrefix(rawToken.substring(0, ComponentAccessToken.PREFIX_SIZE));
for (var token : tokens) {
if (passwordEncoder.matches(rawToken, token.getTokenHash())) {
}
}
}
}

View File

@ -0,0 +1,35 @@
package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* A websocket handler for all websocket connections to the Rail Signal web
* app frontend.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class AppWebsocketHandler extends TextWebSocketHandler {
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
super.afterConnectionEstablished(session);
log.info("App websocket session established.");
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
super.handleTextMessage(session, message);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
super.afterConnectionClosed(session, status);
log.info("App websocket session closed.");
}
}

View File

@ -1,32 +1,39 @@
package nl.andrewl.railsignalapi.websocket; package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository; import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse; import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor; import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Arrays;
import java.util.Map; import java.util.Map;
/**
* This interceptor is used to check incoming websocket connections from the
* web app, to ensure that they're directed towards a valid rail system.
*/
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ComponentWebSocketHandshakeInterceptor implements HandshakeInterceptor { public class AppWebsocketHandshakeInterceptor implements HandshakeInterceptor {
private final RailSystemRepository railSystemRepository; private final RailSystemRepository railSystemRepository;
private final ComponentRepository<nl.andrewl.railsignalapi.model.component.Component> componentRepository;
@Override @Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) {
String[] queryParams = request.getURI().getQuery().split("&"); String path = request.getURI().getPath();
System.out.println(Arrays.toString(queryParams)); Long railSystemId = Long.parseLong(path.substring(path.lastIndexOf('/')));
return false; if (!railSystemRepository.existsById(railSystemId)) {
response.setStatusCode(HttpStatus.NOT_FOUND);
return false;
}
attributes.put("railSystemId", railSystemId);
return true;
} }
@Override @Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
// Nothing to do after the handshake.
} }
} }

View File

@ -0,0 +1,49 @@
package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentAccessTokenRepository;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Handler for websocket connections that components open to send and receive
* real-time updates from the server.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ComponentWebsocketHandler extends TextWebSocketHandler {
private final ComponentAccessTokenRepository tokenRepository;
private final ComponentDownlinkService componentDownlinkService;
@Override
@Transactional(readOnly = true)
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
long tokenId = (long) session.getAttributes().get("tokenId");
var token = tokenRepository.findById(tokenId).orElseThrow();
Set<Long> componentIds = token.getComponents().stream()
.map(nl.andrewl.railsignalapi.model.component.Component::getId)
.collect(Collectors.toSet());
componentDownlinkService.registerDownlink(new WebsocketDownlink(tokenId, session), componentIds);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// var msg = mapper.readValue(message.getPayload(), SignalUpdateMessage.class);
//signalService.handleSignalUpdate(msg);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
componentDownlinkService.deregisterDownlink((long) session.getAttributes().get("tokenId"));
}
}

View File

@ -0,0 +1,56 @@
package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentAccessTokenRepository;
import nl.andrewl.railsignalapi.model.ComponentAccessToken;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
/**
* An interceptor that checks incoming component websocket connections to
* ensure that they have a required "token" query parameter that refers to one
* or more components in a rail system. If the token is valid, we pass its id
* on as an attribute for the handler that will register the connection.
*/
@Component
@RequiredArgsConstructor
public class ComponentWebsocketHandshakeInterceptor implements HandshakeInterceptor {
private final ComponentAccessTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
String query = request.getURI().getQuery();
int tokenIdx = query.lastIndexOf("token=");
if (tokenIdx == -1) {
response.setStatusCode(HttpStatus.BAD_REQUEST);
return false;
}
String rawToken = query.substring(tokenIdx);
if (rawToken.length() < ComponentAccessToken.PREFIX_SIZE) {
response.setStatusCode(HttpStatus.BAD_REQUEST);
return false;
}
Iterable<ComponentAccessToken> tokens = tokenRepository.findAllByTokenPrefix(rawToken.substring(0, ComponentAccessToken.PREFIX_SIZE));
for (var token : tokens) {
if (passwordEncoder.matches(rawToken, token.getTokenHash())) {
attributes.put("tokenId", token.getId());
return true;
}
}
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
// Don't need to do anything after the handshake.
}
}

View File

@ -0,0 +1,40 @@
package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistration;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import java.util.Set;
/**
* Configuration for Rail Signal's websockets. This includes both app and
* component connections.
*/
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebsocketConfig implements WebSocketConfigurer {
private final ComponentWebsocketHandler componentHandler;
private final ComponentWebsocketHandshakeInterceptor componentInterceptor;
private final AppWebsocketHandler appHandler;
private final AppWebsocketHandshakeInterceptor appInterceptor;
private final Environment env;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(componentHandler, "/api/ws/component")
.setAllowedOrigins("*")
.addInterceptors(componentInterceptor);
WebSocketHandlerRegistration appHandlerReg = registry.addHandler(appHandler, "/api/ws/app/*")
.addInterceptors(appInterceptor);
// If we're in a development profile, allow any origin to access the app websocket.
// This is so that we can use a standalone JS dev server.
if (Set.of(env.getActiveProfiles()).contains("development")) {
appHandlerReg.setAllowedOrigins("*");
}
}
}

View File

@ -0,0 +1,20 @@
package nl.andrewl.railsignalapi.live.websocket;
import nl.andrewl.railsignalapi.live.ComponentDownlink;
import nl.andrewl.railsignalapi.util.JsonUtils;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
public class WebsocketDownlink extends ComponentDownlink {
private final WebSocketSession webSocketSession;
public WebsocketDownlink(long id, WebSocketSession webSocketSession) {
super(id);
this.webSocketSession = webSocketSession;
}
@Override
public void send(Object msg) throws Exception {
webSocketSession.sendMessage(new TextMessage(JsonUtils.toJson(msg)));
}
}

View File

@ -18,6 +18,8 @@ import java.util.Set;
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Getter
public class ComponentAccessToken { public class ComponentAccessToken {
public static final byte PREFIX_SIZE = 7;
@Id @Id
@GeneratedValue @GeneratedValue
private Long id; private Long id;
@ -37,7 +39,7 @@ public class ComponentAccessToken {
/** /**
* A short prefix of the token, which is useful for speeding up lookup. * A short prefix of the token, which is useful for speeding up lookup.
*/ */
@Column(nullable = false, length = 7) @Column(nullable = false, length = PREFIX_SIZE)
private String tokenPrefix; private String tokenPrefix;
/** /**

View File

@ -0,0 +1,12 @@
package nl.andrewl.railsignalapi.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class JsonUtils {
private static final ObjectMapper mapper = new ObjectMapper();
public static String toJson(Object o) throws JsonProcessingException {
return mapper.writeValueAsString(o);
}
}

View File

@ -1,21 +0,0 @@
package nl.andrewl.railsignalapi.websocket;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class ComponentWebSocketConfig implements WebSocketConfigurer {
private final SignalWebSocketHandler webSocketHandler;
private final ComponentWebSocketHandshakeInterceptor handshakeInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/api/ws/component")
.addInterceptors(handshakeInterceptor);
}
}

View File

@ -1,47 +0,0 @@
package nl.andrewl.railsignalapi.websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.HashSet;
import java.util.Set;
@Component
@RequiredArgsConstructor
@Slf4j
public class SignalWebSocketHandler extends TextWebSocketHandler {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String signalIdHeader = session.getHandshakeHeaders().getFirst("X-RailSignal-SignalId");
if (signalIdHeader == null || signalIdHeader.isBlank()) {
session.close(CloseStatus.PROTOCOL_ERROR);
return;
}
Set<Long> ids = new HashSet<>();
for (var idStr : signalIdHeader.split(",")) {
ids.add(Long.parseLong(idStr.trim()));
}
//signalService.registerSignalWebSocketSession(ids, session);
log.info("Connection established with signals {}.", ids);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// var msg = mapper.readValue(message.getPayload(), SignalUpdateMessage.class);
//signalService.handleSignalUpdate(msg);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
//signalService.deregisterSignalWebSocketSession(session);
log.info("Closed connection {}. Status: {}", session.getId(), status.toString());
}
}