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

View File

@@ -22,6 +22,7 @@ public class ViewController {
String login = TwitchUser.from(authentication).login();
model.addAttribute("username", login);
model.addAttribute("channel", login);
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(login));
return "dashboard";
}
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.AssetEvent;
import com.imgfloat.app.model.AssetRequest;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import org.springframework.messaging.simp.SimpMessagingTemplate;
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.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
@Service
public class ChannelDirectoryService {
@@ -55,9 +61,16 @@ public class ChannelDirectoryService {
.toList();
}
public Optional<Asset> createAsset(String broadcaster, AssetRequest request) {
public Optional<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException {
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);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
return Optional.of(asset);
@@ -108,6 +121,17 @@ public class ChannelDirectoryService {
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) {
return "/topic/channel/" + broadcaster.toLowerCase();
}

View File

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

View File

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

View File

@@ -16,6 +16,17 @@
<button class="secondary" type="submit">Logout</button>
</form>
</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>
</body>
</html>

View File

@@ -1,7 +1,6 @@
package com.imgfloat.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imgfloat.app.model.AssetRequest;
import com.imgfloat.app.model.VisibilityRequest;
import org.junit.jupiter.api.Test;
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.http.MediaType;
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.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.post;
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.status;
@@ -41,15 +48,10 @@ class ChannelApiIntegrationTest {
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk());
AssetRequest request = new AssetRequest();
request.setUrl("https://example.com/image.png");
request.setWidth(300);
request.setHeight(200);
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
String body = objectMapper.writeValueAsString(request);
String assetId = objectMapper.readTree(mockMvc.perform(post("/api/channels/{broadcaster}/assets", broadcaster)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster)
.file(file)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString()).get("id").asText();
@@ -86,4 +88,11 @@ class ChannelApiIntegrationTest {
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder"))))
.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;
import com.imgfloat.app.model.AssetRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import com.imgfloat.app.service.ChannelDirectoryService;
@@ -8,9 +7,15 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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 javax.imageio.ImageIO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -26,27 +31,20 @@ class ChannelDirectoryServiceTest {
}
@Test
void createsAssetsAndBroadcastsEvents() {
AssetRequest request = new AssetRequest();
request.setUrl("https://example.com/image.png");
request.setWidth(1200);
request.setHeight(800);
void createsAssetsAndBroadcastsEvents() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
Optional<?> created = service.createAsset("caster", request);
Optional<?> created = service.createAsset("caster", file);
assertThat(created).isPresent();
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
}
@Test
void updatesTransformAndVisibility() {
AssetRequest request = new AssetRequest();
request.setUrl("https://example.com/image.png");
request.setWidth(400);
request.setHeight(300);
void updatesTransformAndVisibility() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
String channel = "caster";
String id = service.createAsset(channel, request).orElseThrow().getId();
String id = service.createAsset(channel, file).orElseThrow().getId();
TransformRequest transform = new TransformRequest();
transform.setX(10);
@@ -61,4 +59,11 @@ class ChannelDirectoryServiceTest {
visibilityRequest.setHidden(false);
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();
}
}