mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add listing
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user