Add sqlite

This commit is contained in:
2025-12-04 17:01:49 +01:00
parent 93db7c2d2f
commit 1d1a3a2265
9 changed files with 260 additions and 61 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
local/ local/
*.log *.log
.env .env
*.db

22
pom.xml
View File

@@ -13,6 +13,7 @@
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
<spring.boot.version>3.2.5</spring.boot.version> <spring.boot.version>3.2.5</spring.boot.version>
<hibernate.version>6.4.4.Final</hibernate.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -36,6 +37,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId> <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId> <artifactId>spring-boot-starter-websocket</artifactId>
@@ -53,6 +58,23 @@
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-community-dialects</artifactId>
<version>${hibernate.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.webjars</groupId> <groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId> <artifactId>sockjs-client</artifactId>

View File

@@ -1,10 +1,25 @@
package com.imgfloat.app.model; 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.time.Instant;
import java.util.Locale;
import java.util.UUID; import java.util.UUID;
@Entity
@Table(name = "assets")
public class Asset { public class Asset {
private final String id; @Id
private String id;
@Column(nullable = false)
private String broadcaster;
@Column(columnDefinition = "TEXT")
private String url; private String url;
private double x; private double x;
private double y; private double y;
@@ -12,10 +27,14 @@ public class Asset {
private double height; private double height;
private double rotation; private double rotation;
private boolean hidden; 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.id = UUID.randomUUID().toString();
this.broadcaster = normalize(broadcaster);
this.url = url; this.url = url;
this.width = width; this.width = width;
this.height = height; this.height = height;
@@ -26,10 +45,29 @@ public class Asset {
this.createdAt = Instant.now(); 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() { public String getId() {
return id; return id;
} }
public String getBroadcaster() {
return broadcaster;
}
public void setBroadcaster(String broadcaster) {
this.broadcaster = normalize(broadcaster);
}
public String getUrl() { public String getUrl() {
return url; return url;
} }
@@ -89,4 +127,12 @@ public class Asset {
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
private static String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
} }

View File

@@ -1,19 +1,38 @@
package com.imgfloat.app.model; package com.imgfloat.app.model;
import java.util.Collections; import jakarta.persistence.CollectionTable;
import java.util.Map; import jakarta.persistence.Column;
import java.util.Set; import jakarta.persistence.ElementCollection;
import java.util.concurrent.ConcurrentHashMap; 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 { public class Channel {
private final String broadcaster; @Id
private final Set<String> admins; private String broadcaster;
private final Map<String, Asset> assets;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
@Column(name = "admin_username")
private Set<String> admins = new HashSet<>();
public Channel() {
}
public Channel(String broadcaster) { public Channel(String broadcaster) {
this.broadcaster = broadcaster.toLowerCase(); this.broadcaster = normalize(broadcaster);
this.admins = ConcurrentHashMap.newKeySet();
this.assets = new ConcurrentHashMap<>();
} }
public String getBroadcaster() { public String getBroadcaster() {
@@ -24,15 +43,24 @@ public class Channel {
return Collections.unmodifiableSet(admins); return Collections.unmodifiableSet(admins);
} }
public Map<String, Asset> getAssets() {
return assets;
}
public boolean addAdmin(String username) { public boolean addAdmin(String username) {
return admins.add(username.toLowerCase()); return admins.add(normalize(username));
} }
public boolean removeAdmin(String 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);
} }
} }

View File

@@ -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<Asset, String> {
List<Asset> findByBroadcaster(String broadcaster);
List<Asset> findByBroadcasterAndHiddenFalse(String broadcaster);
}

View File

@@ -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<Channel, String> {
}

View File

@@ -5,6 +5,8 @@ import com.imgfloat.app.model.AssetEvent;
import com.imgfloat.app.model.Channel; import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest; 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.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -15,28 +17,34 @@ import java.io.IOException;
import java.util.Base64; import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
@Service @Service
public class ChannelDirectoryService { public class ChannelDirectoryService {
private final Map<String, Channel> channels = new ConcurrentHashMap<>(); private final ChannelRepository channelRepository;
private final AssetRepository assetRepository;
private final SimpMessagingTemplate messagingTemplate; 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; this.messagingTemplate = messagingTemplate;
} }
public Channel getOrCreateChannel(String broadcaster) { 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) { public boolean addAdmin(String broadcaster, String username) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
boolean added = channel.addAdmin(username); boolean added = channel.addAdmin(username);
if (added) { if (added) {
channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username); messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
} }
return added; return added;
@@ -46,19 +54,18 @@ public class ChannelDirectoryService {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
boolean removed = channel.removeAdmin(username); boolean removed = channel.removeAdmin(username);
if (removed) { if (removed) {
channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username); messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
} }
return removed; return removed;
} }
public Collection<Asset> getAssetsForAdmin(String broadcaster) { public Collection<Asset> getAssetsForAdmin(String broadcaster) {
return getOrCreateChannel(broadcaster).getAssets().values(); return assetRepository.findByBroadcaster(normalize(broadcaster));
} }
public Collection<Asset> getVisibleAssets(String broadcaster) { public Collection<Asset> getVisibleAssets(String broadcaster) {
return getOrCreateChannel(broadcaster).getAssets().values().stream() return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster));
.filter(asset -> !asset.isHidden())
.toList();
} }
public Optional<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException { public Optional<Asset> 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 contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes); String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes);
Asset asset = new Asset(dataUrl, image.getWidth(), image.getHeight()); Asset asset = new Asset(channel.getBroadcaster(), dataUrl, image.getWidth(), image.getHeight());
channel.getAssets().put(asset.getId(), asset); assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
return Optional.of(asset); return Optional.of(asset);
} }
public Optional<Asset> updateTransform(String broadcaster, String assetId, TransformRequest request) { public Optional<Asset> updateTransform(String broadcaster, String assetId, TransformRequest request) {
Channel channel = getOrCreateChannel(broadcaster); String normalized = normalize(broadcaster);
Asset asset = channel.getAssets().get(assetId); return assetRepository.findById(assetId)
if (asset == null) { .filter(asset -> normalized.equals(asset.getBroadcaster()))
return Optional.empty(); .map(asset -> {
} asset.setX(request.getX());
asset.setX(request.getX()); asset.setY(request.getY());
asset.setY(request.getY()); asset.setWidth(request.getWidth());
asset.setWidth(request.getWidth()); asset.setHeight(request.getHeight());
asset.setHeight(request.getHeight()); asset.setRotation(request.getRotation());
asset.setRotation(request.getRotation()); assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset));
return Optional.of(asset); return asset;
});
} }
public Optional<Asset> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { public Optional<Asset> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
Channel channel = getOrCreateChannel(broadcaster); String normalized = normalize(broadcaster);
Asset asset = channel.getAssets().get(assetId); return assetRepository.findById(assetId)
if (asset == null) { .filter(asset -> normalized.equals(asset.getBroadcaster()))
return Optional.empty(); .map(asset -> {
} asset.setHidden(request.isHidden());
asset.setHidden(request.isHidden()); assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset));
return Optional.of(asset); return asset;
});
} }
public boolean deleteAsset(String broadcaster, String assetId) { public boolean deleteAsset(String broadcaster, String assetId) {
Channel channel = getOrCreateChannel(broadcaster); String normalized = normalize(broadcaster);
Asset removed = channel.getAssets().remove(assetId); return assetRepository.findById(assetId)
if (removed != null) { .filter(asset -> normalized.equals(asset.getBroadcaster()))
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId)); .map(asset -> {
return true; assetRepository.delete(asset);
} messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
return false; return true;
})
.orElse(false);
} }
public boolean isBroadcaster(String broadcaster, String username) { public boolean isBroadcaster(String broadcaster, String username) {
@@ -117,8 +128,10 @@ public class ChannelDirectoryService {
} }
public boolean isAdmin(String broadcaster, String username) { public boolean isAdmin(String broadcaster, String username) {
Channel channel = channels.get(broadcaster.toLowerCase()); return channelRepository.findById(normalize(broadcaster))
return channel != null && channel.getAdmins().contains(username.toLowerCase()); .map(Channel::getAdmins)
.map(admins -> admins.contains(normalize(username)))
.orElse(false);
} }
public Collection<String> adminChannelsFor(String username) { public Collection<String> adminChannelsFor(String username) {
@@ -126,7 +139,7 @@ public class ChannelDirectoryService {
return List.of(); return List.of();
} }
String login = username.toLowerCase(); String login = username.toLowerCase();
return channels.values().stream() return channelRepository.findAll().stream()
.filter(channel -> channel.getAdmins().contains(login)) .filter(channel -> channel.getAdmins().contains(login))
.map(Channel::getBroadcaster) .map(Channel::getBroadcaster)
.toList(); .toList();
@@ -135,4 +148,8 @@ public class ChannelDirectoryService {
private String topicFor(String broadcaster) { private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase(); return "/topic/channel/" + broadcaster.toLowerCase();
} }
private String normalize(String value) {
return value == null ? null : value.toLowerCase();
}
} }

View File

@@ -13,6 +13,18 @@ spring:
name: imgfloat name: imgfloat
thymeleaf: thymeleaf:
cache: false 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: security:
oauth2: oauth2:
client: client:

View File

@@ -2,6 +2,10 @@ package com.imgfloat.app;
import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest; 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 com.imgfloat.app.service.ChannelDirectoryService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -12,22 +16,35 @@ import org.springframework.mock.web.MockMultipartFile;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import static org.assertj.core.api.Assertions.assertThat; 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.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
class ChannelDirectoryServiceTest { class ChannelDirectoryServiceTest {
private ChannelDirectoryService service; private ChannelDirectoryService service;
private SimpMessagingTemplate messagingTemplate; private SimpMessagingTemplate messagingTemplate;
private ChannelRepository channelRepository;
private AssetRepository assetRepository;
@BeforeEach @BeforeEach
void setup() { void setup() {
messagingTemplate = mock(SimpMessagingTemplate.class); messagingTemplate = mock(SimpMessagingTemplate.class);
service = new ChannelDirectoryService(messagingTemplate); channelRepository = mock(ChannelRepository.class);
assetRepository = mock(AssetRepository.class);
setupInMemoryPersistence();
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate);
} }
@Test @Test
@@ -66,4 +83,42 @@ class ChannelDirectoryServiceTest {
ImageIO.write(image, "png", out); ImageIO.write(image, "png", out);
return out.toByteArray(); return out.toByteArray();
} }
private void setupInMemoryPersistence() {
Map<String, Channel> channels = new ConcurrentHashMap<>();
Map<String, Asset> 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<Asset> filterAssetsByBroadcaster(Collection<Asset> assets, String broadcaster, boolean onlyVisible) {
return assets.stream()
.filter(asset -> asset.getBroadcaster().equalsIgnoreCase(broadcaster))
.filter(asset -> !onlyVisible || !asset.isHidden())
.toList();
}
} }