diff --git a/.gitignore b/.gitignore
index 068e2e5..640e501 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@
local/
*.log
.env
+*.db
diff --git a/pom.xml b/pom.xml
index fd5b22b..24aa381 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,6 +13,7 @@
17
3.2.5
+ 6.4.4.Final
@@ -36,6 +37,10 @@
org.springframework.boot
spring-boot-starter-thymeleaf
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
org.springframework.boot
spring-boot-starter-websocket
@@ -53,6 +58,23 @@
spring-boot-starter-validation
+
+ org.springframework.session
+ spring-session-jdbc
+
+
+
+ org.xerial
+ sqlite-jdbc
+ runtime
+
+
+
+ org.hibernate.orm
+ hibernate-community-dialects
+ ${hibernate.version}
+
+
org.webjars
sockjs-client
diff --git a/src/main/java/com/imgfloat/app/model/Asset.java b/src/main/java/com/imgfloat/app/model/Asset.java
index c81430f..d27a189 100644
--- a/src/main/java/com/imgfloat/app/model/Asset.java
+++ b/src/main/java/com/imgfloat/app/model/Asset.java
@@ -1,10 +1,25 @@
package com.imgfloat.app.model;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.Table;
+
import java.time.Instant;
+import java.util.Locale;
import java.util.UUID;
+@Entity
+@Table(name = "assets")
public class Asset {
- private final String id;
+ @Id
+ private String id;
+
+ @Column(nullable = false)
+ private String broadcaster;
+
+ @Column(columnDefinition = "TEXT")
private String url;
private double x;
private double y;
@@ -12,10 +27,14 @@ public class Asset {
private double height;
private double rotation;
private boolean hidden;
- private final Instant createdAt;
+ private Instant createdAt;
- public Asset(String url, double width, double height) {
+ public Asset() {
+ }
+
+ public Asset(String broadcaster, String url, double width, double height) {
this.id = UUID.randomUUID().toString();
+ this.broadcaster = normalize(broadcaster);
this.url = url;
this.width = width;
this.height = height;
@@ -26,10 +45,29 @@ public class Asset {
this.createdAt = Instant.now();
}
+ @PrePersist
+ public void prepare() {
+ if (this.id == null) {
+ this.id = UUID.randomUUID().toString();
+ }
+ if (this.createdAt == null) {
+ this.createdAt = Instant.now();
+ }
+ this.broadcaster = normalize(broadcaster);
+ }
+
public String getId() {
return id;
}
+ public String getBroadcaster() {
+ return broadcaster;
+ }
+
+ public void setBroadcaster(String broadcaster) {
+ this.broadcaster = normalize(broadcaster);
+ }
+
public String getUrl() {
return url;
}
@@ -89,4 +127,12 @@ public class Asset {
public Instant getCreatedAt() {
return createdAt;
}
+
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ private static String normalize(String value) {
+ return value == null ? null : value.toLowerCase(Locale.ROOT);
+ }
}
diff --git a/src/main/java/com/imgfloat/app/model/Channel.java b/src/main/java/com/imgfloat/app/model/Channel.java
index b170452..3b57501 100644
--- a/src/main/java/com/imgfloat/app/model/Channel.java
+++ b/src/main/java/com/imgfloat/app/model/Channel.java
@@ -1,19 +1,38 @@
package com.imgfloat.app.model;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.PreUpdate;
+import jakarta.persistence.Table;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Entity
+@Table(name = "channels")
public class Channel {
- private final String broadcaster;
- private final Set admins;
- private final Map assets;
+ @Id
+ private String broadcaster;
+
+ @ElementCollection(fetch = FetchType.EAGER)
+ @CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
+ @Column(name = "admin_username")
+ private Set admins = new HashSet<>();
+
+ public Channel() {
+ }
public Channel(String broadcaster) {
- this.broadcaster = broadcaster.toLowerCase();
- this.admins = ConcurrentHashMap.newKeySet();
- this.assets = new ConcurrentHashMap<>();
+ this.broadcaster = normalize(broadcaster);
}
public String getBroadcaster() {
@@ -24,15 +43,24 @@ public class Channel {
return Collections.unmodifiableSet(admins);
}
- public Map getAssets() {
- return assets;
- }
-
public boolean addAdmin(String username) {
- return admins.add(username.toLowerCase());
+ return admins.add(normalize(username));
}
public boolean removeAdmin(String username) {
- return admins.remove(username.toLowerCase());
+ return admins.remove(normalize(username));
+ }
+
+ @PrePersist
+ @PreUpdate
+ public void normalizeFields() {
+ this.broadcaster = normalize(broadcaster);
+ this.admins = admins.stream()
+ .map(Channel::normalize)
+ .collect(Collectors.toSet());
+ }
+
+ private static String normalize(String value) {
+ return value == null ? null : value.toLowerCase(Locale.ROOT);
}
}
diff --git a/src/main/java/com/imgfloat/app/repository/AssetRepository.java b/src/main/java/com/imgfloat/app/repository/AssetRepository.java
new file mode 100644
index 0000000..416bd49
--- /dev/null
+++ b/src/main/java/com/imgfloat/app/repository/AssetRepository.java
@@ -0,0 +1,11 @@
+package com.imgfloat.app.repository;
+
+import com.imgfloat.app.model.Asset;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface AssetRepository extends JpaRepository {
+ List findByBroadcaster(String broadcaster);
+ List findByBroadcasterAndHiddenFalse(String broadcaster);
+}
diff --git a/src/main/java/com/imgfloat/app/repository/ChannelRepository.java b/src/main/java/com/imgfloat/app/repository/ChannelRepository.java
new file mode 100644
index 0000000..1e245c1
--- /dev/null
+++ b/src/main/java/com/imgfloat/app/repository/ChannelRepository.java
@@ -0,0 +1,7 @@
+package com.imgfloat.app.repository;
+
+import com.imgfloat.app.model.Channel;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ChannelRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java
index 8b68348..3f9be22 100644
--- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java
+++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java
@@ -5,6 +5,8 @@ import com.imgfloat.app.model.AssetEvent;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
+import com.imgfloat.app.repository.AssetRepository;
+import com.imgfloat.app.repository.ChannelRepository;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -15,28 +17,34 @@ import java.io.IOException;
import java.util.Base64;
import java.util.Collection;
import java.util.List;
-import java.util.Map;
import java.util.Optional;
-import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
@Service
public class ChannelDirectoryService {
- private final Map channels = new ConcurrentHashMap<>();
+ private final ChannelRepository channelRepository;
+ private final AssetRepository assetRepository;
private final SimpMessagingTemplate messagingTemplate;
- public ChannelDirectoryService(SimpMessagingTemplate messagingTemplate) {
+ public ChannelDirectoryService(ChannelRepository channelRepository,
+ AssetRepository assetRepository,
+ SimpMessagingTemplate messagingTemplate) {
+ this.channelRepository = channelRepository;
+ this.assetRepository = assetRepository;
this.messagingTemplate = messagingTemplate;
}
public Channel getOrCreateChannel(String broadcaster) {
- return channels.computeIfAbsent(broadcaster.toLowerCase(), Channel::new);
+ String normalized = normalize(broadcaster);
+ return channelRepository.findById(normalized)
+ .orElseGet(() -> channelRepository.save(new Channel(normalized)));
}
public boolean addAdmin(String broadcaster, String username) {
Channel channel = getOrCreateChannel(broadcaster);
boolean added = channel.addAdmin(username);
if (added) {
+ channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
}
return added;
@@ -46,19 +54,18 @@ public class ChannelDirectoryService {
Channel channel = getOrCreateChannel(broadcaster);
boolean removed = channel.removeAdmin(username);
if (removed) {
+ channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
}
return removed;
}
public Collection getAssetsForAdmin(String broadcaster) {
- return getOrCreateChannel(broadcaster).getAssets().values();
+ return assetRepository.findByBroadcaster(normalize(broadcaster));
}
public Collection getVisibleAssets(String broadcaster) {
- return getOrCreateChannel(broadcaster).getAssets().values().stream()
- .filter(asset -> !asset.isHidden())
- .toList();
+ return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster));
}
public Optional createAsset(String broadcaster, MultipartFile file) throws IOException {
@@ -70,46 +77,50 @@ public class ChannelDirectoryService {
}
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes);
- Asset asset = new Asset(dataUrl, image.getWidth(), image.getHeight());
- channel.getAssets().put(asset.getId(), asset);
+ Asset asset = new Asset(channel.getBroadcaster(), dataUrl, image.getWidth(), image.getHeight());
+ assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
return Optional.of(asset);
}
public Optional updateTransform(String broadcaster, String assetId, TransformRequest request) {
- Channel channel = getOrCreateChannel(broadcaster);
- Asset asset = channel.getAssets().get(assetId);
- if (asset == null) {
- return Optional.empty();
- }
- asset.setX(request.getX());
- asset.setY(request.getY());
- asset.setWidth(request.getWidth());
- asset.setHeight(request.getHeight());
- asset.setRotation(request.getRotation());
- messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset));
- return Optional.of(asset);
+ String normalized = normalize(broadcaster);
+ return assetRepository.findById(assetId)
+ .filter(asset -> normalized.equals(asset.getBroadcaster()))
+ .map(asset -> {
+ asset.setX(request.getX());
+ asset.setY(request.getY());
+ asset.setWidth(request.getWidth());
+ asset.setHeight(request.getHeight());
+ asset.setRotation(request.getRotation());
+ assetRepository.save(asset);
+ messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset));
+ return asset;
+ });
}
public Optional updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
- Channel channel = getOrCreateChannel(broadcaster);
- Asset asset = channel.getAssets().get(assetId);
- if (asset == null) {
- return Optional.empty();
- }
- asset.setHidden(request.isHidden());
- messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset));
- return Optional.of(asset);
+ String normalized = normalize(broadcaster);
+ return assetRepository.findById(assetId)
+ .filter(asset -> normalized.equals(asset.getBroadcaster()))
+ .map(asset -> {
+ asset.setHidden(request.isHidden());
+ assetRepository.save(asset);
+ messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset));
+ return asset;
+ });
}
public boolean deleteAsset(String broadcaster, String assetId) {
- Channel channel = getOrCreateChannel(broadcaster);
- Asset removed = channel.getAssets().remove(assetId);
- if (removed != null) {
- messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
- return true;
- }
- return false;
+ String normalized = normalize(broadcaster);
+ return assetRepository.findById(assetId)
+ .filter(asset -> normalized.equals(asset.getBroadcaster()))
+ .map(asset -> {
+ assetRepository.delete(asset);
+ messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
+ return true;
+ })
+ .orElse(false);
}
public boolean isBroadcaster(String broadcaster, String username) {
@@ -117,8 +128,10 @@ public class ChannelDirectoryService {
}
public boolean isAdmin(String broadcaster, String username) {
- Channel channel = channels.get(broadcaster.toLowerCase());
- return channel != null && channel.getAdmins().contains(username.toLowerCase());
+ return channelRepository.findById(normalize(broadcaster))
+ .map(Channel::getAdmins)
+ .map(admins -> admins.contains(normalize(username)))
+ .orElse(false);
}
public Collection adminChannelsFor(String username) {
@@ -126,7 +139,7 @@ public class ChannelDirectoryService {
return List.of();
}
String login = username.toLowerCase();
- return channels.values().stream()
+ return channelRepository.findAll().stream()
.filter(channel -> channel.getAdmins().contains(login))
.map(Channel::getBroadcaster)
.toList();
@@ -135,4 +148,8 @@ public class ChannelDirectoryService {
private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase();
}
+
+ private String normalize(String value) {
+ return value == null ? null : value.toLowerCase();
+ }
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 6935066..48c7b01 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -13,6 +13,18 @@ spring:
name: imgfloat
thymeleaf:
cache: false
+ datasource:
+ url: jdbc:sqlite:imgfloat.db
+ driver-class-name: org.sqlite.JDBC
+ jpa:
+ hibernate:
+ ddl-auto: update
+ database-platform: org.hibernate.community.dialect.SQLiteDialect
+ session:
+ store-type: jdbc
+ jdbc:
+ initialize-schema: always
+ platform: sqlite
security:
oauth2:
client:
diff --git a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java
index 2768353..b958016 100644
--- a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java
+++ b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java
@@ -2,6 +2,10 @@ package com.imgfloat.app;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
+import com.imgfloat.app.model.Asset;
+import com.imgfloat.app.model.Channel;
+import com.imgfloat.app.repository.AssetRepository;
+import com.imgfloat.app.repository.ChannelRepository;
import com.imgfloat.app.service.ChannelDirectoryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -12,22 +16,35 @@ import org.springframework.mock.web.MockMultipartFile;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
class ChannelDirectoryServiceTest {
private ChannelDirectoryService service;
private SimpMessagingTemplate messagingTemplate;
+ private ChannelRepository channelRepository;
+ private AssetRepository assetRepository;
@BeforeEach
void setup() {
messagingTemplate = mock(SimpMessagingTemplate.class);
- service = new ChannelDirectoryService(messagingTemplate);
+ channelRepository = mock(ChannelRepository.class);
+ assetRepository = mock(AssetRepository.class);
+ setupInMemoryPersistence();
+ service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate);
}
@Test
@@ -66,4 +83,42 @@ class ChannelDirectoryServiceTest {
ImageIO.write(image, "png", out);
return out.toByteArray();
}
+
+ private void setupInMemoryPersistence() {
+ Map channels = new ConcurrentHashMap<>();
+ Map assets = new ConcurrentHashMap<>();
+
+ when(channelRepository.findById(anyString()))
+ .thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0))));
+ when(channelRepository.save(any(Channel.class)))
+ .thenAnswer(invocation -> {
+ Channel channel = invocation.getArgument(0);
+ channels.put(channel.getBroadcaster(), channel);
+ return channel;
+ });
+ when(channelRepository.findAll())
+ .thenAnswer(invocation -> List.copyOf(channels.values()));
+
+ when(assetRepository.save(any(Asset.class)))
+ .thenAnswer(invocation -> {
+ Asset asset = invocation.getArgument(0);
+ assets.put(asset.getId(), asset);
+ return asset;
+ });
+ when(assetRepository.findById(anyString()))
+ .thenAnswer(invocation -> Optional.ofNullable(assets.get(invocation.getArgument(0))));
+ when(assetRepository.findByBroadcaster(anyString()))
+ .thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false));
+ when(assetRepository.findByBroadcasterAndHiddenFalse(anyString()))
+ .thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true));
+ doAnswer(invocation -> assets.remove(invocation.getArgument(0, Asset.class).getId()))
+ .when(assetRepository).delete(any(Asset.class));
+ }
+
+ private List filterAssetsByBroadcaster(Collection assets, String broadcaster, boolean onlyVisible) {
+ return assets.stream()
+ .filter(asset -> asset.getBroadcaster().equalsIgnoreCase(broadcaster))
+ .filter(asset -> !onlyVisible || !asset.isHidden())
+ .toList();
+ }
}