diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index 74c53ca..7cefec5 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -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 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") diff --git a/src/main/java/com/imgfloat/app/controller/ViewController.java b/src/main/java/com/imgfloat/app/controller/ViewController.java index 7c8c80c..f963a23 100644 --- a/src/main/java/com/imgfloat/app/controller/ViewController.java +++ b/src/main/java/com/imgfloat/app/controller/ViewController.java @@ -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"; diff --git a/src/main/java/com/imgfloat/app/model/AssetRequest.java b/src/main/java/com/imgfloat/app/model/AssetRequest.java deleted file mode 100644 index 19b911a..0000000 --- a/src/main/java/com/imgfloat/app/model/AssetRequest.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index 35a21af..8b68348 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -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 createAsset(String broadcaster, AssetRequest request) { + public Optional 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 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(); } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 6abfa3a..9055e10 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -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; +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 6ffd880..787c379 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -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 = ''; }); } diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 96b961a..49f4c72 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -27,9 +27,7 @@

Assets

- - - +
    diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 8533130..3c4f886 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -16,6 +16,17 @@ +
    +

    Channels you administer

    +

    No admin invitations yet.

    + +
    diff --git a/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java b/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java index 6d4f85a..129cdf8 100644 --- a/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java +++ b/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java @@ -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(); + } } diff --git a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java index cb3636b..2768353 100644 --- a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java +++ b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java @@ -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 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(); + } }