mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add sqlite
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
||||
local/
|
||||
*.log
|
||||
.env
|
||||
*.db
|
||||
|
||||
22
pom.xml
22
pom.xml
@@ -13,6 +13,7 @@
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<spring.boot.version>3.2.5</spring.boot.version>
|
||||
<hibernate.version>6.4.4.Final</hibernate.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -36,6 +37,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
@@ -53,6 +58,23 @@
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</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>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>sockjs-client</artifactId>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> admins;
|
||||
private final Map<String, Asset> assets;
|
||||
@Id
|
||||
private String broadcaster;
|
||||
|
||||
@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) {
|
||||
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<String, Asset> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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<String, Channel> 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<Asset> getAssetsForAdmin(String broadcaster) {
|
||||
return getOrCreateChannel(broadcaster).getAssets().values();
|
||||
return assetRepository.findByBroadcaster(normalize(broadcaster));
|
||||
}
|
||||
|
||||
public Collection<Asset> getVisibleAssets(String broadcaster) {
|
||||
return getOrCreateChannel(broadcaster).getAssets().values().stream()
|
||||
.filter(asset -> !asset.isHidden())
|
||||
.toList();
|
||||
return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster));
|
||||
}
|
||||
|
||||
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 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<Asset> 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<Asset> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user