Add listing

This commit is contained in:
2025-12-04 16:36:02 +01:00
parent 9f9c14c1bb
commit cc5fa07054
10 changed files with 121 additions and 79 deletions

View File

@@ -2,12 +2,12 @@ package com.imgfloat.app.controller;
import com.imgfloat.app.model.AdminRequest; import com.imgfloat.app.model.AdminRequest;
import com.imgfloat.app.model.Asset; import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetRequest;
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.service.ChannelDirectoryService; import com.imgfloat.app.service.ChannelDirectoryService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -18,11 +18,14 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.util.Collection; import java.util.Collection;
import java.io.IOException;
import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequestMapping("/api/channels/{broadcaster}") @RequestMapping("/api/channels/{broadcaster}")
@@ -82,15 +85,22 @@ public class ChannelApiController {
return channelDirectoryService.getVisibleAssets(broadcaster); return channelDirectoryService.getVisibleAssets(broadcaster);
} }
@PostMapping("/assets") @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Asset> createAsset(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<Asset> createAsset(@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody AssetRequest request, @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login); ensureAuthorized(broadcaster, login);
return channelDirectoryService.createAsset(broadcaster, request) if (file == null || file.isEmpty()) {
.map(ResponseEntity::ok) throw new ResponseStatusException(BAD_REQUEST, "Asset file is required");
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Channel not found")); }
try {
return channelDirectoryService.createAsset(broadcaster, file)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
} catch (IOException e) {
throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e);
}
} }
@PutMapping("/assets/{assetId}/transform") @PutMapping("/assets/{assetId}/transform")

View File

@@ -22,6 +22,7 @@ public class ViewController {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
model.addAttribute("username", login); model.addAttribute("username", login);
model.addAttribute("channel", login); model.addAttribute("channel", login);
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(login));
return "dashboard"; return "dashboard";
} }
return "index"; return "index";

View File

@@ -1,39 +0,0 @@
package com.imgfloat.app.model;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public class AssetRequest {
@NotBlank
private String url;
@Min(1)
private double width;
@Min(1)
private double height;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
public double getHeight() {
return height;
}
public void setHeight(double height) {
this.height = height;
}
}

View File

@@ -2,17 +2,23 @@ package com.imgfloat.app.service;
import com.imgfloat.app.model.Asset; import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetEvent; import com.imgfloat.app.model.AssetEvent;
import com.imgfloat.app.model.AssetRequest;
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 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 java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
@Service @Service
public class ChannelDirectoryService { public class ChannelDirectoryService {
@@ -55,9 +61,16 @@ public class ChannelDirectoryService {
.toList(); .toList();
} }
public Optional<Asset> createAsset(String broadcaster, AssetRequest request) { public Optional<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
Asset asset = new Asset(request.getUrl(), request.getWidth(), request.getHeight()); byte[] bytes = file.getBytes();
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image == null) {
return Optional.empty();
}
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); channel.getAssets().put(asset.getId(), 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);
@@ -108,6 +121,17 @@ public class ChannelDirectoryService {
return channel != null && channel.getAdmins().contains(username.toLowerCase()); return channel != null && channel.getAdmins().contains(username.toLowerCase());
} }
public Collection<String> adminChannelsFor(String username) {
if (username == null) {
return List.of();
}
String login = username.toLowerCase();
return channels.values().stream()
.filter(channel -> channel.getAdmins().contains(login))
.map(Channel::getBroadcaster)
.toList();
}
private String topicFor(String broadcaster) { private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase(); return "/topic/channel/" + broadcaster.toLowerCase();
} }

View File

@@ -82,3 +82,21 @@ body {
overflow: hidden; overflow: hidden;
background: transparent; background: transparent;
} }
.panel {
margin-top: 24px;
padding: 16px;
background: #0b1220;
border-radius: 10px;
border: 1px solid #1f2937;
}
.panel ul {
list-style: none;
padding: 0;
margin: 8px 0 0 0;
}
.panel li {
margin: 6px 0;
}

View File

@@ -62,13 +62,18 @@ function draw() {
} }
function uploadAsset() { function uploadAsset() {
const url = document.getElementById('asset-url').value; const fileInput = document.getElementById('asset-file');
const width = parseFloat(document.getElementById('asset-width').value); if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
const height = parseFloat(document.getElementById('asset-height').value); alert('Please choose an image to upload.');
return;
}
const data = new FormData();
data.append('file', fileInput.files[0]);
fetch(`/api/channels/${broadcaster}/assets`, { fetch(`/api/channels/${broadcaster}/assets`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, body: data
body: JSON.stringify({url, width, height}) }).then(() => {
fileInput.value = '';
}); });
} }

View File

@@ -27,9 +27,7 @@
</div> </div>
<div> <div>
<h3>Assets</h3> <h3>Assets</h3>
<input id="asset-url" placeholder="Asset URL" /> <input id="asset-file" type="file" accept="image/*" />
<input id="asset-width" placeholder="Width" type="number" value="600" />
<input id="asset-height" placeholder="Height" type="number" value="400" />
<button onclick="uploadAsset()">Upload</button> <button onclick="uploadAsset()">Upload</button>
<ul id="asset-list"></ul> <ul id="asset-list"></ul>
</div> </div>

View File

@@ -16,6 +16,17 @@
<button class="secondary" type="submit">Logout</button> <button class="secondary" type="submit">Logout</button>
</form> </form>
</div> </div>
<div th:if="${adminChannels != null}"
class="panel">
<h3>Channels you administer</h3>
<p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p>
<ul th:if="${!#lists.isEmpty(adminChannels)}">
<li th:each="channelName : ${adminChannels}">
<a th:href="@{'/view/' + ${channelName} + '/admin'}"
th:text="${channelName}">channel</a>
</li>
</ul>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,7 +1,6 @@
package com.imgfloat.app; package com.imgfloat.app;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imgfloat.app.model.AssetRequest;
import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.model.VisibilityRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -9,6 +8,13 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.mock.web.MockMultipartFile;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login;
@@ -16,6 +22,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -41,15 +48,10 @@ class ChannelApiIntegrationTest {
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk()); .andExpect(status().isOk());
AssetRequest request = new AssetRequest(); MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
request.setUrl("https://example.com/image.png");
request.setWidth(300);
request.setHeight(200);
String body = objectMapper.writeValueAsString(request); String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster)
String assetId = objectMapper.readTree(mockMvc.perform(post("/api/channels/{broadcaster}/assets", broadcaster) .file(file)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andReturn().getResponse().getContentAsString()).get("id").asText(); .andReturn().getResponse().getContentAsString()).get("id").asText();
@@ -86,4 +88,11 @@ class ChannelApiIntegrationTest {
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder")))) .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder"))))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
private byte[] samplePng() throws IOException {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(image, "png", out);
return out.toByteArray();
}
} }

View File

@@ -1,6 +1,5 @@
package com.imgfloat.app; package com.imgfloat.app;
import com.imgfloat.app.model.AssetRequest;
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.service.ChannelDirectoryService; import com.imgfloat.app.service.ChannelDirectoryService;
@@ -8,9 +7,15 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.mock.web.MockMultipartFile;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Optional; import java.util.Optional;
import javax.imageio.ImageIO;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -26,27 +31,20 @@ class ChannelDirectoryServiceTest {
} }
@Test @Test
void createsAssetsAndBroadcastsEvents() { void createsAssetsAndBroadcastsEvents() throws Exception {
AssetRequest request = new AssetRequest(); MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
request.setUrl("https://example.com/image.png");
request.setWidth(1200);
request.setHeight(800);
Optional<?> created = service.createAsset("caster", request); Optional<?> created = service.createAsset("caster", file);
assertThat(created).isPresent(); assertThat(created).isPresent();
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class); ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture()); verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
} }
@Test @Test
void updatesTransformAndVisibility() { void updatesTransformAndVisibility() throws Exception {
AssetRequest request = new AssetRequest(); MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
request.setUrl("https://example.com/image.png");
request.setWidth(400);
request.setHeight(300);
String channel = "caster"; String channel = "caster";
String id = service.createAsset(channel, request).orElseThrow().getId(); String id = service.createAsset(channel, file).orElseThrow().getId();
TransformRequest transform = new TransformRequest(); TransformRequest transform = new TransformRequest();
transform.setX(10); transform.setX(10);
@@ -61,4 +59,11 @@ class ChannelDirectoryServiceTest {
visibilityRequest.setHidden(false); visibilityRequest.setHidden(false);
assertThat(service.updateVisibility(channel, id, visibilityRequest)).isPresent(); assertThat(service.updateVisibility(channel, id, visibilityRequest)).isPresent();
} }
private byte[] samplePng() throws IOException {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(image, "png", out);
return out.toByteArray();
}
} }