Add redir

This commit is contained in:
2025-12-02 17:00:19 +01:00
parent 60f04f1aa8
commit 80b58147ed
16 changed files with 198 additions and 195 deletions

View File

@@ -3,7 +3,8 @@ APP_NAME=imgfloat
.PHONY: run test package docker-build docker-run ssl .PHONY: run test package docker-build docker-run ssl
run: run:
test -f .env && . ./.env test -f .env && . ./.env; \
export TWITCH_REDIRECT_URI=$${TWITCH_REDIRECT_URI:-http://localhost:8080/login/oauth2/code/twitch}; \
mvn spring-boot:run mvn spring-boot:run
test: test:

View File

@@ -6,7 +6,7 @@ A Spring Boot overlay server for Twitch broadcasters and their channel admins. B
- Twitch OAuth (OAuth2 login) with broadcaster and channel admin access controls. - Twitch OAuth (OAuth2 login) with broadcaster and channel admin access controls.
- Admin console with Twitch player embed and canvas preview. - Admin console with Twitch player embed and canvas preview.
- Broadcaster overlay view optimized for OBS browser sources. - Broadcaster overlay view optimized for OBS browser sources.
- Real-time image creation, movement, resize, rotation, visibility toggles, and deletion via STOMP/WebSockets. - Real-time asset creation, movement, resize, rotation, visibility toggles, and deletion via STOMP/WebSockets.
- In-memory channel directory optimized with lock-free collections for fast updates. - In-memory channel directory optimized with lock-free collections for fast updates.
- Optional SSL with local self-signed keystore support. - Optional SSL with local self-signed keystore support.
- Dockerfile, Makefile, CI workflow, and Maven build. - Dockerfile, Makefile, CI workflow, and Maven build.
@@ -19,9 +19,10 @@ A Spring Boot overlay server for Twitch broadcasters and their channel admins. B
### Local run ### Local run
```bash ```bash
TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret mvn spring-boot:run TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret \
TWITCH_REDIRECT_URI=http://localhost:8080/login/oauth2/code/twitch mvn spring-boot:run
``` ```
The default server port is `8080`. Log in via `/oauth2/authorization/twitch`. The default server port is `8080`. Log in via `/oauth2/authorization/twitch`. The redirect URI above is what Twitch should be configured to call for local development.
### Enable TLS locally ### Enable TLS locally
```bash ```bash
@@ -32,7 +33,7 @@ mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8443"
``` ```
### Make targets ### Make targets
- `make run` start the dev server. - `make run` start the dev server (exports `TWITCH_REDIRECT_URI` to `http://localhost:8080/login/oauth2/code/twitch` if unset).
- `make test` run unit/integration tests. - `make test` run unit/integration tests.
- `make package` build the runnable jar. - `make package` build the runnable jar.
- `make docker-build` / `make docker-run` containerize and run the service. - `make docker-build` / `make docker-run` containerize and run the service.
@@ -45,7 +46,7 @@ TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret docker run -p 8080:808
``` ```
### OAuth configuration ### OAuth configuration
Spring Boot reads Twitch credentials from `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`. The redirect URI is `{baseUrl}/login/oauth2/code/twitch`. Spring Boot reads Twitch credentials from `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`. The redirect URI comes from `TWITCH_REDIRECT_URI` (defaulting to `{baseUrl}/login/oauth2/code/twitch`).
### CI ### CI
GitHub Actions runs `mvn verify` on pushes and pull requests via `.github/workflows/ci.yml`. GitHub Actions runs `mvn verify` on pushes and pull requests via `.github/workflows/ci.yml`.

View File

@@ -1,8 +1,8 @@
package com.imgfloat.app.controller; package com.imgfloat.app.controller;
import com.imgfloat.app.model.AdminRequest; import com.imgfloat.app.model.AdminRequest;
import com.imgfloat.app.model.ImageLayer; import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.ImageRequest; 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;
@@ -61,71 +61,71 @@ public class ChannelApiController {
return ResponseEntity.ok().body(removed); return ResponseEntity.ok().body(removed);
} }
@GetMapping("/images") @GetMapping("/assets")
public Collection<ImageLayer> listImages(@PathVariable("broadcaster") String broadcaster, public Collection<Asset> listAssets(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login) if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) { && !channelDirectoryService.isAdmin(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Not authorized"); throw new ResponseStatusException(FORBIDDEN, "Not authorized");
} }
return channelDirectoryService.getImagesForAdmin(broadcaster); return channelDirectoryService.getAssetsForAdmin(broadcaster);
} }
@GetMapping("/images/visible") @GetMapping("/assets/visible")
public Collection<ImageLayer> listVisible(@PathVariable("broadcaster") String broadcaster, public Collection<Asset> listVisible(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) { if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only broadcaster can load public overlay"); throw new ResponseStatusException(FORBIDDEN, "Only broadcaster can load public overlay");
} }
return channelDirectoryService.getVisibleImages(broadcaster); return channelDirectoryService.getVisibleAssets(broadcaster);
} }
@PostMapping("/images") @PostMapping("/assets")
public ResponseEntity<ImageLayer> createImage(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<Asset> createAsset(@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody ImageRequest request, @Valid @RequestBody AssetRequest request,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login); ensureAuthorized(broadcaster, login);
return channelDirectoryService.createImage(broadcaster, request) return channelDirectoryService.createAsset(broadcaster, request)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Channel not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Channel not found"));
} }
@PutMapping("/images/{imageId}/transform") @PutMapping("/assets/{assetId}/transform")
public ResponseEntity<ImageLayer> transform(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<Asset> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId, @PathVariable("assetId") String assetId,
@Valid @RequestBody TransformRequest request, @Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login); ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateTransform(broadcaster, imageId, request) return channelDirectoryService.updateTransform(broadcaster, assetId, request)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Image not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
} }
@PutMapping("/images/{imageId}/visibility") @PutMapping("/assets/{assetId}/visibility")
public ResponseEntity<ImageLayer> visibility(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<Asset> visibility(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId, @PathVariable("assetId") String assetId,
@RequestBody VisibilityRequest request, @RequestBody VisibilityRequest request,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login); ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateVisibility(broadcaster, imageId, request) return channelDirectoryService.updateVisibility(broadcaster, assetId, request)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Image not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
} }
@DeleteMapping("/images/{imageId}") @DeleteMapping("/assets/{assetId}")
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId, @PathVariable("assetId") String assetId,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login); ensureAuthorized(broadcaster, login);
boolean removed = channelDirectoryService.deleteImage(broadcaster, imageId); boolean removed = channelDirectoryService.deleteAsset(broadcaster, assetId);
if (!removed) { if (!removed) {
throw new ResponseStatusException(NOT_FOUND, "Image not found"); throw new ResponseStatusException(NOT_FOUND, "Asset not found");
} }
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }

View File

@@ -3,7 +3,7 @@ package com.imgfloat.app.model;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
public class ImageLayer { public class Asset {
private final String id; private final String id;
private String url; private String url;
private double x; private double x;
@@ -14,7 +14,7 @@ public class ImageLayer {
private boolean hidden; private boolean hidden;
private final Instant createdAt; private final Instant createdAt;
public ImageLayer(String url, double width, double height) { public Asset(String url, double width, double height) {
this.id = UUID.randomUUID().toString(); this.id = UUID.randomUUID().toString();
this.url = url; this.url = url;
this.width = width; this.width = width;

View File

@@ -0,0 +1,66 @@
package com.imgfloat.app.model;
public class AssetEvent {
public enum Type {
CREATED,
UPDATED,
VISIBILITY,
DELETED
}
private Type type;
private String channel;
private Asset payload;
private String assetId;
public static AssetEvent created(String channel, Asset asset) {
AssetEvent event = new AssetEvent();
event.type = Type.CREATED;
event.channel = channel;
event.payload = asset;
event.assetId = asset.getId();
return event;
}
public static AssetEvent updated(String channel, Asset asset) {
AssetEvent event = new AssetEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.payload = asset;
event.assetId = asset.getId();
return event;
}
public static AssetEvent visibility(String channel, Asset asset) {
AssetEvent event = new AssetEvent();
event.type = Type.VISIBILITY;
event.channel = channel;
event.payload = asset;
event.assetId = asset.getId();
return event;
}
public static AssetEvent deleted(String channel, String assetId) {
AssetEvent event = new AssetEvent();
event.type = Type.DELETED;
event.channel = channel;
event.assetId = assetId;
return event;
}
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public Asset getPayload() {
return payload;
}
public String getAssetId() {
return assetId;
}
}

View File

@@ -3,7 +3,7 @@ package com.imgfloat.app.model;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public class ImageRequest { public class AssetRequest {
@NotBlank @NotBlank
private String url; private String url;

View File

@@ -8,12 +8,12 @@ import java.util.concurrent.ConcurrentHashMap;
public class Channel { public class Channel {
private final String broadcaster; private final String broadcaster;
private final Set<String> admins; private final Set<String> admins;
private final Map<String, ImageLayer> images; private final Map<String, Asset> assets;
public Channel(String broadcaster) { public Channel(String broadcaster) {
this.broadcaster = broadcaster.toLowerCase(); this.broadcaster = broadcaster.toLowerCase();
this.admins = ConcurrentHashMap.newKeySet(); this.admins = ConcurrentHashMap.newKeySet();
this.images = new ConcurrentHashMap<>(); this.assets = new ConcurrentHashMap<>();
} }
public String getBroadcaster() { public String getBroadcaster() {
@@ -24,8 +24,8 @@ public class Channel {
return Collections.unmodifiableSet(admins); return Collections.unmodifiableSet(admins);
} }
public Map<String, ImageLayer> getImages() { public Map<String, Asset> getAssets() {
return images; return assets;
} }
public boolean addAdmin(String username) { public boolean addAdmin(String username) {

View File

@@ -1,66 +0,0 @@
package com.imgfloat.app.model;
public class ImageEvent {
public enum Type {
CREATED,
UPDATED,
VISIBILITY,
DELETED
}
private Type type;
private String channel;
private ImageLayer payload;
private String imageId;
public static ImageEvent created(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.CREATED;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent updated(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent visibility(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.VISIBILITY;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent deleted(String channel, String imageId) {
ImageEvent event = new ImageEvent();
event.type = Type.DELETED;
event.channel = channel;
event.imageId = imageId;
return event;
}
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public ImageLayer getPayload() {
return payload;
}
public String getImageId() {
return imageId;
}
}

View File

@@ -1,9 +1,9 @@
package com.imgfloat.app.service; 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.Channel;
import com.imgfloat.app.model.ImageEvent;
import com.imgfloat.app.model.ImageLayer;
import com.imgfloat.app.model.ImageRequest;
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;
@@ -45,55 +45,55 @@ public class ChannelDirectoryService {
return removed; return removed;
} }
public Collection<ImageLayer> getImagesForAdmin(String broadcaster) { public Collection<Asset> getAssetsForAdmin(String broadcaster) {
return getOrCreateChannel(broadcaster).getImages().values(); return getOrCreateChannel(broadcaster).getAssets().values();
} }
public Collection<ImageLayer> getVisibleImages(String broadcaster) { public Collection<Asset> getVisibleAssets(String broadcaster) {
return getOrCreateChannel(broadcaster).getImages().values().stream() return getOrCreateChannel(broadcaster).getAssets().values().stream()
.filter(image -> !image.isHidden()) .filter(asset -> !asset.isHidden())
.toList(); .toList();
} }
public Optional<ImageLayer> createImage(String broadcaster, ImageRequest request) { public Optional<Asset> createAsset(String broadcaster, AssetRequest request) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = new ImageLayer(request.getUrl(), request.getWidth(), request.getHeight()); Asset asset = new Asset(request.getUrl(), request.getWidth(), request.getHeight());
channel.getImages().put(layer.getId(), layer); channel.getAssets().put(asset.getId(), asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.created(broadcaster, layer)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
return Optional.of(layer); return Optional.of(asset);
} }
public Optional<ImageLayer> updateTransform(String broadcaster, String imageId, TransformRequest request) { public Optional<Asset> updateTransform(String broadcaster, String assetId, TransformRequest request) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = channel.getImages().get(imageId); Asset asset = channel.getAssets().get(assetId);
if (layer == null) { if (asset == null) {
return Optional.empty(); return Optional.empty();
} }
layer.setX(request.getX()); asset.setX(request.getX());
layer.setY(request.getY()); asset.setY(request.getY());
layer.setWidth(request.getWidth()); asset.setWidth(request.getWidth());
layer.setHeight(request.getHeight()); asset.setHeight(request.getHeight());
layer.setRotation(request.getRotation()); asset.setRotation(request.getRotation());
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.updated(broadcaster, layer)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset));
return Optional.of(layer); return Optional.of(asset);
} }
public Optional<ImageLayer> updateVisibility(String broadcaster, String imageId, VisibilityRequest request) { public Optional<Asset> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = channel.getImages().get(imageId); Asset asset = channel.getAssets().get(assetId);
if (layer == null) { if (asset == null) {
return Optional.empty(); return Optional.empty();
} }
layer.setHidden(request.isHidden()); asset.setHidden(request.isHidden());
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.visibility(broadcaster, layer)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset));
return Optional.of(layer); return Optional.of(asset);
} }
public boolean deleteImage(String broadcaster, String imageId) { public boolean deleteAsset(String broadcaster, String assetId) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
ImageLayer removed = channel.getImages().remove(imageId); Asset removed = channel.getAssets().remove(assetId);
if (removed != null) { if (removed != null) {
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.deleted(broadcaster, imageId)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
return true; return true;
} }
return false; return false;

View File

@@ -20,7 +20,7 @@ spring:
twitch: twitch:
client-id: ${TWITCH_CLIENT_ID} client-id: ${TWITCH_CLIENT_ID}
client-secret: ${TWITCH_CLIENT_SECRET} client-secret: ${TWITCH_CLIENT_SECRET}
redirect-uri: "{baseUrl}/login/oauth2/code/twitch" redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
authorization-grant-type: authorization_code authorization-grant-type: authorization_code
scope: ["user:read:email"] scope: ["user:read:email"]
provider: provider:

View File

@@ -3,7 +3,7 @@ const canvas = document.getElementById('admin-canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth; canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight; canvas.height = canvas.offsetHeight;
const images = new Map(); const assets = new Map();
function connect() { function connect() {
const socket = new SockJS('/ws'); const socket = new SockJS('/ws');
@@ -13,13 +13,13 @@ function connect() {
const body = JSON.parse(payload.body); const body = JSON.parse(payload.body);
handleEvent(body); handleEvent(body);
}); });
fetchImages(); fetchAssets();
fetchAdmins(); fetchAdmins();
}); });
} }
function fetchImages() { function fetchAssets() {
fetch(`/api/channels/${broadcaster}/images`).then(r => r.json()).then(renderImages); fetch(`/api/channels/${broadcaster}/assets`).then(r => r.json()).then(renderAssets);
} }
function fetchAdmins() { function fetchAdmins() {
@@ -34,38 +34,38 @@ function fetchAdmins() {
}).catch(() => {}); }).catch(() => {});
} }
function renderImages(list) { function renderAssets(list) {
list.forEach(img => images.set(img.id, img)); list.forEach(asset => assets.set(asset.id, asset));
draw(); draw();
} }
function handleEvent(event) { function handleEvent(event) {
if (event.type === 'DELETED') { if (event.type === 'DELETED') {
images.delete(event.imageId); assets.delete(event.assetId);
} else if (event.payload) { } else if (event.payload) {
images.set(event.payload.id, event.payload); assets.set(event.payload.id, event.payload);
} }
draw(); draw();
} }
function draw() { function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
images.forEach(img => { assets.forEach(asset => {
ctx.save(); ctx.save();
ctx.globalAlpha = img.hidden ? 0.35 : 1; ctx.globalAlpha = asset.hidden ? 0.35 : 1;
ctx.translate(img.x, img.y); ctx.translate(asset.x, asset.y);
ctx.rotate(img.rotation * Math.PI / 180); ctx.rotate(asset.rotation * Math.PI / 180);
ctx.fillStyle = 'rgba(124, 58, 237, 0.25)'; ctx.fillStyle = 'rgba(124, 58, 237, 0.25)';
ctx.fillRect(0, 0, img.width, img.height); ctx.fillRect(0, 0, asset.width, asset.height);
ctx.restore(); ctx.restore();
}); });
} }
function uploadImage() { function uploadAsset() {
const url = document.getElementById('image-url').value; const url = document.getElementById('asset-url').value;
const width = parseFloat(document.getElementById('image-width').value); const width = parseFloat(document.getElementById('asset-width').value);
const height = parseFloat(document.getElementById('image-height').value); const height = parseFloat(document.getElementById('asset-height').value);
fetch(`/api/channels/${broadcaster}/images`, { fetch(`/api/channels/${broadcaster}/assets`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url, width, height}) body: JSON.stringify({url, width, height})

View File

@@ -2,7 +2,7 @@ const canvas = document.getElementById('broadcast-canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth; canvas.width = window.innerWidth;
canvas.height = window.innerHeight; canvas.height = window.innerHeight;
const images = new Map(); const assets = new Map();
function connect() { function connect() {
const socket = new SockJS('/ws'); const socket = new SockJS('/ws');
@@ -12,37 +12,37 @@ function connect() {
const body = JSON.parse(payload.body); const body = JSON.parse(payload.body);
handleEvent(body); handleEvent(body);
}); });
fetch(`/api/channels/${broadcaster}/images/visible`).then(r => r.json()).then(renderImages); fetch(`/api/channels/${broadcaster}/assets/visible`).then(r => r.json()).then(renderAssets);
}); });
} }
function renderImages(list) { function renderAssets(list) {
list.forEach(img => images.set(img.id, img)); list.forEach(asset => assets.set(asset.id, asset));
draw(); draw();
} }
function handleEvent(event) { function handleEvent(event) {
if (event.type === 'DELETED') { if (event.type === 'DELETED') {
images.delete(event.imageId); assets.delete(event.assetId);
} else if (event.payload && !event.payload.hidden) { } else if (event.payload && !event.payload.hidden) {
images.set(event.payload.id, event.payload); assets.set(event.payload.id, event.payload);
} else if (event.payload && event.payload.hidden) { } else if (event.payload && event.payload.hidden) {
images.delete(event.payload.id); assets.delete(event.payload.id);
} }
draw(); draw();
} }
function draw() { function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
images.forEach(img => { assets.forEach(asset => {
ctx.save(); ctx.save();
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.translate(img.x, img.y); ctx.translate(asset.x, asset.y);
ctx.rotate(img.rotation * Math.PI / 180); ctx.rotate(asset.rotation * Math.PI / 180);
const image = new Image(); const image = new Image();
image.src = img.url; image.src = asset.url;
image.onload = () => { image.onload = () => {
ctx.drawImage(image, 0, 0, img.width, img.height); ctx.drawImage(image, 0, 0, asset.width, asset.height);
}; };
ctx.restore(); ctx.restore();
}); });

View File

@@ -26,12 +26,12 @@
<ul id="admin-list"></ul> <ul id="admin-list"></ul>
</div> </div>
<div> <div>
<h3>Images</h3> <h3>Assets</h3>
<input id="image-url" placeholder="Image URL" /> <input id="asset-url" placeholder="Asset URL" />
<input id="image-width" placeholder="Width" type="number" value="600" /> <input id="asset-width" placeholder="Width" type="number" value="600" />
<input id="image-height" placeholder="Height" type="number" value="400" /> <input id="asset-height" placeholder="Height" type="number" value="400" />
<button onclick="uploadImage()">Upload</button> <button onclick="uploadAsset()">Upload</button>
<ul id="image-list"></ul> <ul id="asset-list"></ul>
</div> </div>
</section> </section>
<section class="overlay"> <section class="overlay">

View File

@@ -1,7 +1,7 @@
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.ImageRequest; 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;
@@ -33,7 +33,7 @@ class ChannelApiIntegrationTest {
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@Test @Test
void broadcasterManagesAdminsAndImages() throws Exception { void broadcasterManagesAdminsAndAssets() throws Exception {
String broadcaster = "caster"; String broadcaster = "caster";
mockMvc.perform(post("/api/channels/{broadcaster}/admins", broadcaster) mockMvc.perform(post("/api/channels/{broadcaster}/admins", broadcaster)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -41,39 +41,39 @@ 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());
ImageRequest request = new ImageRequest(); AssetRequest request = new AssetRequest();
request.setUrl("https://example.com/image.png"); request.setUrl("https://example.com/image.png");
request.setWidth(300); request.setWidth(300);
request.setHeight(200); request.setHeight(200);
String body = objectMapper.writeValueAsString(request); String body = objectMapper.writeValueAsString(request);
String imageId = objectMapper.readTree(mockMvc.perform(post("/api/channels/{broadcaster}/images", broadcaster) String assetId = objectMapper.readTree(mockMvc.perform(post("/api/channels/{broadcaster}/assets", broadcaster)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body) .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();
mockMvc.perform(get("/api/channels/{broadcaster}/images", broadcaster) mockMvc.perform(get("/api/channels/{broadcaster}/assets", broadcaster)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1))); .andExpect(jsonPath("$", hasSize(1)));
VisibilityRequest visibilityRequest = new VisibilityRequest(); VisibilityRequest visibilityRequest = new VisibilityRequest();
visibilityRequest.setHidden(false); visibilityRequest.setHidden(false);
mockMvc.perform(put("/api/channels/{broadcaster}/images/{id}/visibility", broadcaster, imageId) mockMvc.perform(put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(visibilityRequest)) .content(objectMapper.writeValueAsString(visibilityRequest))
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.hidden").value(false)); .andExpect(jsonPath("$.hidden").value(false));
mockMvc.perform(get("/api/channels/{broadcaster}/images/visible", broadcaster) mockMvc.perform(get("/api/channels/{broadcaster}/assets/visible", broadcaster)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1))); .andExpect(jsonPath("$", hasSize(1)));
mockMvc.perform(delete("/api/channels/{broadcaster}/images/{id}", broadcaster, imageId) mockMvc.perform(delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }

View File

@@ -1,6 +1,6 @@
package com.imgfloat.app; package com.imgfloat.app;
import com.imgfloat.app.model.ImageRequest; 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;
@@ -26,13 +26,13 @@ class ChannelDirectoryServiceTest {
} }
@Test @Test
void createsImagesAndBroadcastsEvents() { void createsAssetsAndBroadcastsEvents() {
ImageRequest request = new ImageRequest(); AssetRequest request = new AssetRequest();
request.setUrl("https://example.com/image.png"); request.setUrl("https://example.com/image.png");
request.setWidth(1200); request.setWidth(1200);
request.setHeight(800); request.setHeight(800);
Optional<?> created = service.createImage("caster", request); Optional<?> created = service.createAsset("caster", request);
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());
@@ -40,13 +40,13 @@ class ChannelDirectoryServiceTest {
@Test @Test
void updatesTransformAndVisibility() { void updatesTransformAndVisibility() {
ImageRequest request = new ImageRequest(); AssetRequest request = new AssetRequest();
request.setUrl("https://example.com/image.png"); request.setUrl("https://example.com/image.png");
request.setWidth(400); request.setWidth(400);
request.setHeight(300); request.setHeight(300);
String channel = "caster"; String channel = "caster";
String id = service.createImage(channel, request).orElseThrow().getId(); String id = service.createAsset(channel, request).orElseThrow().getId();
TransformRequest transform = new TransformRequest(); TransformRequest transform = new TransformRequest();
transform.setX(10); transform.setX(10);

View File

@@ -1,2 +1,3 @@
TWITCH_CLIENT_ID=test-client-id TWITCH_CLIENT_ID=test-client-id
TWITCH_CLIENT_SECRET=test-client-secret TWITCH_CLIENT_SECRET=test-client-secret
TWITCH_REDIRECT_URI=http://localhost:8080/login/oauth2/code/twitch