mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add signup block display
This commit is contained in:
@@ -4,11 +4,15 @@ import com.imgfloat.app.model.AdminRequest;
|
|||||||
import com.imgfloat.app.model.Asset;
|
import com.imgfloat.app.model.Asset;
|
||||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||||
import com.imgfloat.app.model.TransformRequest;
|
import com.imgfloat.app.model.TransformRequest;
|
||||||
|
import com.imgfloat.app.model.TwitchUserProfile;
|
||||||
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 com.imgfloat.app.service.TwitchUserLookupService;
|
||||||
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.http.MediaType;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||||
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;
|
||||||
@@ -23,6 +27,9 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
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;
|
||||||
@@ -32,9 +39,15 @@ import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
|||||||
@RequestMapping("/api/channels/{broadcaster}")
|
@RequestMapping("/api/channels/{broadcaster}")
|
||||||
public class ChannelApiController {
|
public class ChannelApiController {
|
||||||
private final ChannelDirectoryService channelDirectoryService;
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
|
private final OAuth2AuthorizedClientService authorizedClientService;
|
||||||
|
private final TwitchUserLookupService twitchUserLookupService;
|
||||||
|
|
||||||
public ChannelApiController(ChannelDirectoryService channelDirectoryService) {
|
public ChannelApiController(ChannelDirectoryService channelDirectoryService,
|
||||||
|
OAuth2AuthorizedClientService authorizedClientService,
|
||||||
|
TwitchUserLookupService twitchUserLookupService) {
|
||||||
this.channelDirectoryService = channelDirectoryService;
|
this.channelDirectoryService = channelDirectoryService;
|
||||||
|
this.authorizedClientService = authorizedClientService;
|
||||||
|
this.twitchUserLookupService = twitchUserLookupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/admins")
|
@PostMapping("/admins")
|
||||||
@@ -48,11 +61,26 @@ public class ChannelApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admins")
|
@GetMapping("/admins")
|
||||||
public Collection<String> listAdmins(@PathVariable("broadcaster") String broadcaster,
|
public Collection<TwitchUserProfile> listAdmins(@PathVariable("broadcaster") String broadcaster,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken authentication) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
ensureBroadcaster(broadcaster, login);
|
||||||
return channelDirectoryService.getOrCreateChannel(broadcaster).getAdmins();
|
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||||
|
List<String> admins = channel.getAdmins().stream()
|
||||||
|
.sorted(Comparator.naturalOrder())
|
||||||
|
.toList();
|
||||||
|
OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient(
|
||||||
|
authentication.getAuthorizedClientRegistrationId(),
|
||||||
|
authentication.getName());
|
||||||
|
String accessToken = Optional.ofNullable(authorizedClient)
|
||||||
|
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||||
|
.map(token -> token.getTokenValue())
|
||||||
|
.orElse(null);
|
||||||
|
String clientId = Optional.ofNullable(authorizedClient)
|
||||||
|
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||||
|
.map(registration -> registration.getClientId())
|
||||||
|
.orElse(null);
|
||||||
|
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/admins/{username}")
|
@DeleteMapping("/admins/{username}")
|
||||||
|
|||||||
12
src/main/java/com/imgfloat/app/model/TwitchUserProfile.java
Normal file
12
src/main/java/com/imgfloat/app/model/TwitchUserProfile.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.imgfloat.app.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Twitch user details used for rendering avatars and display names.
|
||||||
|
*/
|
||||||
|
public record TwitchUserProfile(String login, String displayName, String avatarUrl) {
|
||||||
|
public TwitchUserProfile {
|
||||||
|
if (displayName == null || displayName.isBlank()) {
|
||||||
|
displayName = login;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.imgfloat.app.service;
|
||||||
|
|
||||||
|
import com.imgfloat.app.model.TwitchUserProfile;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TwitchUserLookupService {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(TwitchUserLookupService.class);
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
public TwitchUserLookupService(RestTemplateBuilder builder) {
|
||||||
|
this.restTemplate = builder
|
||||||
|
.setConnectTimeout(Duration.ofSeconds(15))
|
||||||
|
.setReadTimeout(Duration.ofSeconds(15))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TwitchUserProfile> fetchProfiles(Collection<String> logins, String accessToken, String clientId) {
|
||||||
|
if (logins == null || logins.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> normalizedLogins = logins.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
||||||
|
return normalizedLogins.stream()
|
||||||
|
.map(login -> new TwitchUserProfile(login, login, null))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setBearerAuth(accessToken);
|
||||||
|
headers.add("Client-ID", clientId);
|
||||||
|
|
||||||
|
UriComponentsBuilder uriBuilder = UriComponentsBuilder
|
||||||
|
.fromHttpUrl("https://api.twitch.tv/helix/users");
|
||||||
|
normalizedLogins.forEach(login -> uriBuilder.queryParam("login", login));
|
||||||
|
|
||||||
|
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||||
|
try {
|
||||||
|
ResponseEntity<TwitchUsersResponse> response = restTemplate.exchange(
|
||||||
|
uriBuilder.build(true).toUri(),
|
||||||
|
HttpMethod.GET,
|
||||||
|
entity,
|
||||||
|
TwitchUsersResponse.class);
|
||||||
|
|
||||||
|
Map<String, TwitchUserData> byLogin = response.getBody() == null
|
||||||
|
? Collections.emptyMap()
|
||||||
|
: response.getBody().data().stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
user -> user.login().toLowerCase(Locale.ROOT),
|
||||||
|
Function.identity(),
|
||||||
|
(a, b) -> a));
|
||||||
|
|
||||||
|
return normalizedLogins.stream()
|
||||||
|
.map(login -> toProfile(login, byLogin.get(login)))
|
||||||
|
.toList();
|
||||||
|
} catch (RestClientException ex) {
|
||||||
|
LOG.warn("Unable to fetch Twitch user profiles", ex);
|
||||||
|
return normalizedLogins.stream()
|
||||||
|
.map(login -> new TwitchUserProfile(login, login, null))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TwitchUserProfile toProfile(String login, TwitchUserData data) {
|
||||||
|
if (data == null) {
|
||||||
|
return new TwitchUserProfile(login, login, null);
|
||||||
|
}
|
||||||
|
return new TwitchUserProfile(login, data.displayName(), data.profileImageUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record TwitchUsersResponse(List<TwitchUserData> data) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record TwitchUserData(
|
||||||
|
String login,
|
||||||
|
@JsonProperty("display_name") String displayName,
|
||||||
|
@JsonProperty("profile_image_url") String profileImageUrl) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -574,3 +574,32 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.identity-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e0e7ff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-fallback {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,35 @@ function renderAdmins(list) {
|
|||||||
|
|
||||||
list.forEach((admin) => {
|
list.forEach((admin) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.textContent = admin;
|
li.className = 'stacked-list-item';
|
||||||
|
|
||||||
|
const identity = document.createElement('div');
|
||||||
|
identity.className = 'identity-row';
|
||||||
|
|
||||||
|
const avatar = document.createElement(admin.avatarUrl ? 'img' : 'div');
|
||||||
|
avatar.className = 'avatar';
|
||||||
|
if (admin.avatarUrl) {
|
||||||
|
avatar.src = admin.avatarUrl;
|
||||||
|
avatar.alt = `${admin.displayName || admin.login} avatar`;
|
||||||
|
} else {
|
||||||
|
avatar.classList.add('avatar-fallback');
|
||||||
|
avatar.textContent = (admin.displayName || admin.login || '?').charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = document.createElement('div');
|
||||||
|
details.className = 'identity-text';
|
||||||
|
const title = document.createElement('p');
|
||||||
|
title.className = 'list-title';
|
||||||
|
title.textContent = admin.displayName || admin.login;
|
||||||
|
const subtitle = document.createElement('p');
|
||||||
|
subtitle.className = 'muted';
|
||||||
|
subtitle.textContent = `@${admin.login}`;
|
||||||
|
|
||||||
|
details.appendChild(title);
|
||||||
|
details.appendChild(subtitle);
|
||||||
|
identity.appendChild(avatar);
|
||||||
|
identity.appendChild(details);
|
||||||
|
li.appendChild(identity);
|
||||||
adminList.appendChild(li);
|
adminList.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ 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());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/channels/{broadcaster}/admins", broadcaster)
|
||||||
|
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].login").value("helper"))
|
||||||
|
.andExpect(jsonPath("$[0].displayName").value("helper"));
|
||||||
|
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
|
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
|
||||||
|
|
||||||
String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster)
|
String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster)
|
||||||
|
|||||||
Reference in New Issue
Block a user