From 1d1a3a22651ffe2941505ec0b9b634fd47fc2161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 4 Dec 2025 17:01:49 +0100 Subject: [PATCH] Add sqlite --- .gitignore | 1 + pom.xml | 22 +++++ .../java/com/imgfloat/app/model/Asset.java | 52 +++++++++- .../java/com/imgfloat/app/model/Channel.java | 60 ++++++++--- .../app/repository/AssetRepository.java | 11 +++ .../app/repository/ChannelRepository.java | 7 ++ .../app/service/ChannelDirectoryService.java | 99 +++++++++++-------- src/main/resources/application.yml | 12 +++ .../app/ChannelDirectoryServiceTest.java | 57 ++++++++++- 9 files changed, 260 insertions(+), 61 deletions(-) create mode 100644 src/main/java/com/imgfloat/app/repository/AssetRepository.java create mode 100644 src/main/java/com/imgfloat/app/repository/ChannelRepository.java 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(); + } }