Compare commits

..

13 Commits

45 changed files with 669 additions and 369 deletions
GetToken.javacompose-branch-deploy.yaml
core/src
docker-compose.yml
test-data/src/main/java/se/su/dsv/scipro/testdata/populators
view/src
war/src/main

@ -5,7 +5,9 @@ import java.awt.Toolkit;
import java.awt.datatransfer.StringSelection;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@ -13,7 +15,6 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
@ -26,9 +27,16 @@ public class GetToken {
String clientId = "get-token";
String clientSecret = "get-token-secret";
System.out.println("Browse to " + baseUri.resolve("oauth2/authorize?response_type=code&client_id=" + clientId));
System.out.println("Browse to " + baseUri.resolve("authorize?response_type=code&client_id=" + clientId));
HttpClient httpClient = HttpClient.newBuilder().build();
HttpClient httpClient = HttpClient.newBuilder()
.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(clientId, clientSecret.toCharArray());
}
})
.build();
HttpServer httpServer = HttpServer.create();
httpServer.bind(new InetSocketAddress(59732), 0);
@ -44,11 +52,8 @@ public class GetToken {
Map<String, List<String>> queryParams = getQueryParams(exchange);
String code = queryParams.get("code").get(0);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(baseUri.resolve("oauth2/token"))
.uri(baseUri.resolve("exchange"))
.header("Content-Type", "application/x-www-form-urlencoded")
// Have to preemptively set the authorization header
// Spring Authorization server does not send a WWW-Authenticate header which would trigger the above Authenticator
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8)))
.POST(HttpRequest.BodyPublishers.ofString("grant_type=authorization_code&code=" + code))
.build();
try {

@ -13,9 +13,14 @@ services:
- JDBC_DATABASE_URL=jdbc:mariadb://db:3306/scipro
- JDBC_DATABASE_USERNAME=scipro
- JDBC_DATABASE_PASSWORD=scipro
- OAUTH2_ISSUER_URI=https://oauth2-${VHOST}
- OAUTH2_AUTHORIZATION_URI=https://oauth2-${VHOST}/authorize
- OAUTH2_TOKEN_URI=https://oauth2-${VHOST}/exchange
- OAUTH2_USER_INFO_URI=https://oauth2-${VHOST}/introspect
- OAUTH2_CLIENT_ID=scipro_client
- OAUTH2_CLIENT_SECRET=scipro_secret
- OAUTH2_RESOURCE_SERVER_ID=scipro_api_client
- OAUTH2_RESOURCE_SERVER_SECRET=scipro_api_secret
- OAUTH2_RESOURCE_SERVER_INTROSPECTION_URI=https://oauth2-${VHOST}/introspect
- OAUTH2_GS_AUTHORIZATION_URI=https://oauth2-gs-${VHOST}
- OAUTH2_GS_CLIENT_REDIRECT_URI=https://${VHOST}/oauth/callback
networks:
@ -44,13 +49,16 @@ services:
retries: 6
oauth2:
build: https://gitea.dsv.su.se/DMC/oauth2-authorization-server.git#1d469c73468d00be5430dac01a7ab84f11ed471a
build:
context: https://github.com/dsv-su/toker.git
dockerfile: embedded.Dockerfile
restart: unless-stopped
environment:
- CLIENT_ID=scipro_client
- CLIENT_SECRET=scipro_secret
- CLIENT_REDIRECT_URI=https://${VHOST}/login/oauth2/code/scipro
- CLIENT_SCOPES=openid
- RESOURCE_SERVER_ID=scipro_api_client
- RESOURCE_SERVER_SECRET=scipro_api_secret
networks:
- traefik
labels:
@ -59,12 +67,16 @@ services:
- "traefik.http.routers.oauth2-${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt"
oauth2-gs:
build: https://gitea.dsv.su.se/DMC/oauth2-authorization-server.git#1d469c73468d00be5430dac01a7ab84f11ed471a
build:
context: https://github.com/dsv-su/toker.git
dockerfile: embedded.Dockerfile
restart: unless-stopped
environment:
- CLIENT_ID=scipro_client
- CLIENT_SECRET=scipro_secret
- CLIENT_REDIRECT_URI=https://${VHOST}/oauth/callback
- RESOURCE_SERVER_ID=scipro_api_client
- RESOURCE_SERVER_SECRET=scipro_api_secret
- CLIENT_SCOPES=grade:read grade:write
networks:
- traefik

@ -22,6 +22,8 @@ public interface BasicForumService extends Serializable {
ForumThread createThread(String subject);
ForumThread findThreadById(Long id);
long countUnreadThreads(List<ForumThread> forumThreadList, User user);
ForumPost getLastPost(ForumThread forumThread);
@ -31,4 +33,8 @@ public interface BasicForumService extends Serializable {
boolean canDelete(ForumPost forumPost);
void deletePost(ForumPost post);
boolean canDelete(ForumThread forumThread);
void deleteThread(ForumThread forumThread);
}

@ -91,6 +91,11 @@ public class BasicForumServiceImpl implements BasicForumService {
return threadRepository.save(forumThread);
}
@Override
public ForumThread findThreadById(Long id) {
return threadRepository.findOne(id);
}
@Override
public long countUnreadThreads(List<ForumThread> forumThreadList, User user) {
return postRepository.countUnreadThreads(forumThreadList, user);
@ -179,8 +184,35 @@ public class BasicForumServiceImpl implements BasicForumService {
if (!canDelete(post)) {
throw new PostCantBeDeletedException();
}
post.setDeleted(true);
postRepository.save(post);
ForumThread forumThread = post.getForumThread();
forumThread.getPosts().remove(post);
threadRepository.save(forumThread);
}
@Override
public boolean canDelete(ForumThread forumThread) {
boolean hasReplies = getPosts(forumThread).size() > 1;
if (hasReplies) {
return false;
}
User currentUser = currentUserProvider.get();
if (currentUser == null) {
// Allow the system to delete any thread
return true;
}
return Objects.equals(currentUser, forumThread.getCreatedBy());
}
@Override
@Transactional
public void deleteThread(ForumThread forumThread) {
if (!canDelete(forumThread)) {
throw new IllegalArgumentException("Not allowed to delete thread");
}
threadRepository.delete(forumThread);
}
private static final class PostCantBeDeletedException extends IllegalArgumentException {

@ -18,4 +18,12 @@ public interface GroupForumService {
ForumPost createReply(GroupThread groupThread, User poster, String content, Set<Attachment> attachments);
List<ForumPost> getPosts(GroupThread groupThread);
boolean canDelete(GroupThread groupThread, ForumPost post);
void deletePost(GroupThread groupThread, ForumPost post);
boolean canDeleteThread(GroupThread groupThread);
void deleteThread(GroupThread groupThread);
}

@ -81,4 +81,25 @@ public class GroupForumServiceImpl implements GroupForumService {
public List<ForumPost> getPosts(final GroupThread groupThread) {
return basicForumService.getPosts(groupThread.getForumThread());
}
@Override
public boolean canDelete(GroupThread groupThread, ForumPost post) {
return basicForumService.canDelete(post);
}
@Override
public void deletePost(GroupThread groupThread, ForumPost post) {
basicForumService.deletePost(post);
}
@Override
public boolean canDeleteThread(GroupThread groupThread) {
return basicForumService.canDelete(groupThread.getForumThread());
}
@Override
public void deleteThread(GroupThread groupThread) {
groupThreadRepository.delete(groupThread);
basicForumService.deleteThread(groupThread.getForumThread());
}
}

@ -0,0 +1,5 @@
package se.su.dsv.scipro.forum;
import se.su.dsv.scipro.project.Project;
public record ProjectForumPostDeletedEvent(Project project, String subject) {}

@ -24,4 +24,12 @@ public interface ProjectForumService {
List<Pair<ProjectThread, ForumPost>> latestPost(Project a, int amount);
long getUnreadThreadsCount(Project project, User user);
boolean canDelete(ProjectThread projectThread, ForumPost post);
void deletePost(ProjectThread projectThread, ForumPost post);
boolean canDelete(ProjectThread projectThread);
void deleteThread(ProjectThread projectThread);
}

@ -121,6 +121,34 @@ public class ProjectForumServiceImpl implements ProjectForumService {
return basicForumService.countUnreadThreads(list, user);
}
@Override
public boolean canDelete(ProjectThread projectThread, ForumPost post) {
return basicForumService.canDelete(post);
}
@Override
public void deletePost(ProjectThread projectThread, ForumPost post) {
basicForumService.deletePost(post);
eventBus.post(
new ProjectForumPostDeletedEvent(projectThread.getProject(), projectThread.getForumThread().getSubject())
);
}
@Override
public boolean canDelete(ProjectThread projectThread) {
return basicForumService.canDelete(projectThread.getForumThread());
}
@Override
@Transactional
public void deleteThread(ProjectThread projectThread) {
Project project = projectThread.getProject();
String subject = projectThread.getForumThread().getSubject();
projectThreadRepository.delete(projectThread);
basicForumService.deleteThread(projectThread.getForumThread());
eventBus.post(new ProjectForumThreadDeletedEvent(project, subject));
}
@Override
public ProjectThread findOne(long threadId) {
return projectThreadRepository.findOne(threadId);

@ -0,0 +1,5 @@
package se.su.dsv.scipro.forum;
import se.su.dsv.scipro.project.Project;
public record ProjectForumThreadDeletedEvent(Project project, String subject) {}

@ -11,11 +11,11 @@ import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.OneToMany;
import jakarta.persistence.PostLoad;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
import se.su.dsv.scipro.system.LazyDeletableDomainObject;
import se.su.dsv.scipro.system.User;
@ -38,17 +38,9 @@ public class ForumThread extends LazyDeletableDomainObject {
// ----------------------------------------------------------------------------------
// JPA-mappings of other tables referencing to this table "thread"
// ----------------------------------------------------------------------------------
@OneToMany(mappedBy = "forumThread", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@OneToMany(mappedBy = "forumThread", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true)
private List<ForumPost> posts = new ArrayList<>();
// ----------------------------------------------------------------------------------
// JPA-lifecycle method
// ----------------------------------------------------------------------------------
@PostLoad
void lazyDeletion() {
posts.removeIf(LazyDeletableDomainObject::isDeleted);
}
// ----------------------------------------------------------------------------------
// Properties (Getters and Setters)
// ----------------------------------------------------------------------------------
@ -109,8 +101,8 @@ public class ForumThread extends LazyDeletableDomainObject {
posts.add(post);
}
public int getPostCount() {
return posts.size();
public long getPostCount() {
return posts.stream().filter(Predicate.not(ForumPost::isDeleted)).count();
}
public User getCreatedBy() {

@ -9,6 +9,8 @@ import java.util.function.Function;
import se.su.dsv.scipro.forum.ForumPostReadEvent;
import se.su.dsv.scipro.forum.NewGroupForumReplyEvent;
import se.su.dsv.scipro.forum.NewProjectForumReplyEvent;
import se.su.dsv.scipro.forum.ProjectForumPostDeletedEvent;
import se.su.dsv.scipro.forum.ProjectForumThreadDeletedEvent;
import se.su.dsv.scipro.forum.dataobjects.ForumPost;
import se.su.dsv.scipro.notifications.NotificationController;
import se.su.dsv.scipro.notifications.NotificationService;
@ -95,6 +97,24 @@ public class ForumNotifications {
});
}
@Subscribe
public void projectThreadDeleted(ProjectForumThreadDeletedEvent event) {
NotificationSource source = new NotificationSource();
source.setMessage(event.subject());
notificationController.notifyProjectForum(
ProjectForumEvent.Event.FORUM_THREAD_DELETED,
source,
event.project()
);
}
@Subscribe
public void projectForumPostDeleted(ProjectForumPostDeletedEvent event) {
NotificationSource source = new NotificationSource();
source.setMessage(event.subject());
notificationController.notifyProjectForum(ProjectForumEvent.Event.FORUM_POST_DELETED, source, event.project());
}
private void sendAndConnect(ForumPost post, Function<NotificationSource, Set<Notification>> send) {
NotificationSource notificationSource = new NotificationSource();
notificationSource.setMessage(String.format("Posted by %s\n\n%s", getPostedBy(post), post.getContent()));

@ -23,6 +23,8 @@ FORUM.NEW_FORUM_POST_COMMENT.body = {0}
FORUM.NEW_REVIEWER_INTERACTION.body = {0}
GROUP.MESSAGE_THREAD_CREATED.body = {0}
GROUP.MESSAGE_THREAD_REPLY.body = {0}
FORUM.FORUM_THREAD_DELETED.title = Forum thread {1} deleted in project {0}
FORUM.FORUM_POST_DELETED.title = Reply deleted in thread {1} in project {0}
PROJECT = \
***********************************\n\n\

@ -17,6 +17,8 @@ public class ProjectForumEvent extends NotificationEvent {
NEW_FORUM_POST,
NEW_FORUM_POST_COMMENT,
NEW_REVIEWER_INTERACTION,
FORUM_THREAD_DELETED,
FORUM_POST_DELETED,
}
@Basic

@ -99,6 +99,10 @@ FORUM.NEW_FORUM_POST_COMMENT.title = Forum reply: {2}
FORUM.NEW_FORUM_POST_COMMENT.body = New forum reply: {1}<br /><br />{0}
FORUM.NEW_REVIEWER_INTERACTION.title = Reviewer interaction updated in project: {0}
FORUM.NEW_REVIEWER_INTERACTION.body = New message in reviewer interaction: {1}<br /><br />{0}
FORUM.FORUM_THREAD_DELETED.title = Forum thread {1} deleted
FORUM.FORUM_THREAD_DELETED.body = The forum thread {0} was deleted.
FORUM.FORUM_POST_DELETED.title = Reply deleted in thread {1}
FORUM.FORUM_POST_DELETED.body = A reply was deleted in the thread {0}.
FORUM.compilationSuffix =
GROUP.MESSAGE_THREAD_CREATED.title = Group forum: {2}

@ -1,4 +0,0 @@
alter table idea_student
add constraint fk_idea_student_program_id
foreign key (program_id) references program (id)
on update cascade on delete set null;

@ -1,198 +0,0 @@
package se.su.dsv.scipro.forum;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.common.eventbus.EventBus;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.AdditionalAnswers;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import se.su.dsv.scipro.forum.dataobjects.ForumPost;
import se.su.dsv.scipro.forum.dataobjects.ForumPostReadState;
import se.su.dsv.scipro.forum.dataobjects.ForumThread;
import se.su.dsv.scipro.system.User;
import se.su.dsv.scipro.test.ForumBuilder;
import se.su.dsv.scipro.test.UserBuilder;
@ExtendWith(MockitoExtension.class)
public class BasicForumServiceImplTest {
@Mock
private AbstractThreadRepository threadRepository;
@Mock
private ForumPostReadStateRepository readStateRepository;
@Mock
private ForumPostRepository postRepository;
@Mock
private EventBus eventBus;
@InjectMocks
private BasicForumServiceImpl basicForumService;
@Test
public void testGetPostPageByForumThread() {
List<ForumPost> posts = Collections.singletonList(new ForumBuilder().createPost());
when(postRepository.findByThread(isA(ForumThread.class))).thenReturn(posts);
List<ForumPost> servicePage = basicForumService.getPosts(mock(ForumThread.class));
assertEquals(posts, servicePage);
}
@Test
public void testMarkRead() {
when(readStateRepository.find(any(User.class), any(ForumPost.class))).thenReturn(new ForumPostReadState());
when(readStateRepository.save(isA(ForumPostReadState.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
User user = new User();
ForumPost post = new ForumPost();
boolean read = basicForumService.setRead(user, post, true);
assertTrue(read, "Did not return proper read state");
ArgumentCaptor<ForumPostReadState> captor = ArgumentCaptor.forClass(ForumPostReadState.class);
verify(readStateRepository, times(1)).save(captor.capture());
assertTrue(captor.getValue().isRead(), "Did not save correct read state");
}
@Test
public void testMarkUnread() {
when(readStateRepository.find(any(User.class), any(ForumPost.class))).thenReturn(new ForumPostReadState());
when(readStateRepository.save(isA(ForumPostReadState.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
// when
User user = new User();
ForumPost post = new ForumPost();
// when
boolean read = basicForumService.setRead(user, post, false);
// then
assertFalse(read, "Did not return proper read state");
ArgumentCaptor<ForumPostReadState> captor = ArgumentCaptor.forClass(ForumPostReadState.class);
verify(readStateRepository, times(1)).save(captor.capture());
assertFalse(captor.getValue().isRead(), "Did not save correct read state");
}
@Test
public void testMarkThreadReadPostsEvent() {
User user = new User();
ForumPost post = new ForumPost();
post.setContent("post 1");
ForumPost post2 = new ForumPost();
post2.setContent("post 2");
ForumThread forumThread = new ForumThread();
forumThread.addPost(post);
forumThread.addPost(post2);
basicForumService.setThreadRead(user, forumThread, true);
verify(eventBus).post(new ForumPostReadEvent(post, user));
verify(eventBus).post(new ForumPostReadEvent(post2, user));
}
@Test
public void testIsThreadRead() {
User goodUser = new UserBuilder().setFirstName("Reads").setLastName("Forum").create();
User badUser = new UserBuilder().setFirstName("Does not read").setLastName("Forum").create();
ForumPost post = new ForumPost();
ForumPostReadState readState = new ForumPostReadState(goodUser, post);
readState.setRead(true);
ForumPostReadState notReadState = new ForumPostReadState(badUser, post);
notReadState.setRead(false);
ForumThread forumThread = new ForumThread();
forumThread.addPost(post);
when(postRepository.findByThread(forumThread)).thenReturn(List.of(post));
when(readStateRepository.find(eq(goodUser), isA(ForumPost.class))).thenReturn(readState);
when(readStateRepository.find(eq(badUser), isA(ForumPost.class))).thenReturn(notReadState);
boolean goodUserState = basicForumService.isThreadRead(goodUser, forumThread);
boolean badUserState = basicForumService.isThreadRead(badUser, forumThread);
assertTrue(goodUserState, "Good user has not read all thread posts");
assertFalse(badUserState, "Bad user has read all thread posts");
}
@Test
public void create_forum_thread() {
when(threadRepository.save(any(ForumThread.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
final String subject = "Subject";
ForumThread thread = basicForumService.createThread(subject);
assertThat(thread.getSubject(), is(subject));
}
@Test
public void reply_to_thread() {
when(readStateRepository.find(any(User.class), any(ForumPost.class))).thenReturn(new ForumPostReadState());
when(readStateRepository.save(isA(ForumPostReadState.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
when(threadRepository.save(any(ForumThread.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
when(postRepository.save(any(ForumPost.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
final ForumThread forumThread = new ForumThread();
final User poster = User.builder().firstName("Bob").lastName("Example").emailAddress("bob@example.com").build();
final String content = "content";
ForumPost forumPost = basicForumService.createReply(forumThread, poster, content, Collections.emptySet());
assertThat(forumPost.getContent(), is(content));
assertThat(forumPost.getPostedBy(), is(poster));
assertThat(forumPost.getForumThread(), is(forumThread));
}
@Test
public void mark_post_read_posts_event() {
when(readStateRepository.find(any(User.class), any(ForumPost.class))).thenReturn(new ForumPostReadState());
when(readStateRepository.save(isA(ForumPostReadState.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
final ForumPost post = new ForumPost();
post.setId(235235L);
final User user = new User();
user.setId(2378924L);
basicForumService.setRead(user, post, true);
ArgumentCaptor<ForumPostReadEvent> captor = ArgumentCaptor.forClass(ForumPostReadEvent.class);
verify(eventBus).post(captor.capture());
ForumPostReadEvent event = captor.getValue();
assertEquals(event.post(), post);
assertEquals(event.user(), user);
}
@Test
public void mark_post_unread_does_not_post_read_event() {
when(readStateRepository.find(any(User.class), any(ForumPost.class))).thenReturn(new ForumPostReadState());
when(readStateRepository.save(isA(ForumPostReadState.class))).thenAnswer(AdditionalAnswers.returnsFirstArg());
final ForumPost post = new ForumPost();
post.setId(235235L);
final User user = new User();
user.setId(2378924L);
basicForumService.setRead(user, post, false);
verify(eventBus, never()).post(isA(ForumPostReadEvent.class));
}
}

@ -1,11 +1,17 @@
package se.su.dsv.scipro.forum;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import jakarta.inject.Inject;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -80,4 +86,157 @@ public class BasicForumServiceIntegrationTest extends IntegrationTest {
assertTrue(basicForumService.canDelete(secondReply));
assertDoesNotThrow(() -> basicForumService.deletePost(secondReply));
}
@Test
public void testGetPostPageByForumThread() {
ForumThread thread = basicForumService.createThread("Test thread");
ForumPost post1 = basicForumService.createReply(thread, op, "Test post 1", Set.of());
ForumPost post2 = basicForumService.createReply(thread, commenter, "Test post 2", Set.of());
List<ForumPost> posts = basicForumService.getPosts(thread);
assertThat(posts, contains(post1, post2));
}
@Test
public void testMarkRead() {
ForumThread thread = basicForumService.createThread("Test thread");
ForumPost post = basicForumService.createReply(thread, op, "Test post 1", Set.of());
boolean read = basicForumService.setRead(commenter, post, true);
assertTrue(read, "Did not return proper read state");
boolean isRead = basicForumService.isRead(commenter, post);
assertTrue(isRead, "Did not save correct read state");
}
@Test
public void testMarkUnread() {
ForumThread thread = basicForumService.createThread("Test thread");
ForumPost post = basicForumService.createReply(thread, op, "Test post 1", Set.of());
boolean read = basicForumService.setRead(op, post, false);
assertFalse(read, "Did not return proper read state");
boolean isRead = basicForumService.isRead(commenter, post);
assertFalse(isRead, "Did not save correct read state");
}
@Test
public void testMarkThreadReadPostsEvent() {
ForumThread thread = basicForumService.createThread("Test thread");
ForumPost post1 = basicForumService.createReply(thread, op, "Test post 1", Set.of());
ForumPost post2 = basicForumService.createReply(thread, op, "Test post 2", Set.of());
basicForumService.setThreadRead(commenter, thread, true);
assertThat(getPublishedEvents(), hasItem(new ForumPostReadEvent(post1, commenter)));
assertThat(getPublishedEvents(), hasItem(new ForumPostReadEvent(post2, commenter)));
}
@Test
public void testIsThreadRead() {
ForumThread thread = basicForumService.createThread("Test thread");
basicForumService.createReply(thread, op, "Test post 1", Set.of());
basicForumService.setThreadRead(commenter, thread, true);
basicForumService.createReply(thread, commenter, "Test post 2", Set.of());
boolean goodUserState = basicForumService.isThreadRead(commenter, thread);
boolean badUserState = basicForumService.isThreadRead(op, thread);
assertTrue(goodUserState, "Good user has not read all thread posts");
assertFalse(badUserState, "Bad user has read all thread posts");
}
@Test
public void mark_post_read_posts_event() {
ForumThread thread = basicForumService.createThread("Test thread");
ForumPost post = basicForumService.createReply(thread, op, "Test post 1", Set.of());
basicForumService.setRead(commenter, post, true);
assertThat(getPublishedEvents(), hasItem(new ForumPostReadEvent(post, commenter)));
}
@Test
public void mark_post_unread_does_not_post_read_event() {
ForumThread thread = basicForumService.createThread("Test thread");
ForumPost post = basicForumService.createReply(thread, op, "Test post 1", Set.of());
basicForumService.setRead(commenter, post, false);
assertThat(getPublishedEvents(), not(hasItem(new ForumPostReadEvent(post, commenter))));
}
@Test
public void can_delete_own_thread_without_replies() {
ForumThread thread = basicForumService.createThread("Test thread");
basicForumService.createReply(thread, op, "Test post 1", Set.of());
setLoggedInAs(op);
assertTrue(basicForumService.canDelete(thread));
}
@Test
public void cant_delete_others_threads_without_replies() {
ForumThread thread = basicForumService.createThread("Test thread");
basicForumService.createReply(thread, op, "Test post 1", Set.of());
setLoggedInAs(commenter);
assertFalse(basicForumService.canDelete(thread));
}
@Test
public void system_can_delete_all_threads() {
ForumThread thread = basicForumService.createThread("Test thread");
basicForumService.createReply(thread, op, "Test post 1", Set.of());
setLoggedInAs(null);
assertTrue(basicForumService.canDelete(thread));
}
@Test
public void can_not_delete_thread_with_replies() {
ForumThread thread = basicForumService.createThread("Test thread");
basicForumService.createReply(thread, op, "Test post 1", Set.of());
basicForumService.createReply(thread, commenter, "Test post 2", Set.of());
setLoggedInAs(op);
assertFalse(basicForumService.canDelete(thread));
}
@Test
public void deleting_thread_removes_it() {
ForumThread thread = basicForumService.createThread("Test thread");
basicForumService.createReply(thread, op, "Test post 1", Set.of());
setLoggedInAs(op);
assertTrue(basicForumService.canDelete(thread));
assertDoesNotThrow(() -> basicForumService.deleteThread(thread));
ForumThread threadById = basicForumService.findThreadById(thread.getId());
assertNull(threadById, "Thread still exists");
}
@Test
public void trying_to_delete_thread_with_replies_throws() {
ForumThread thread = basicForumService.createThread("Test thread");
basicForumService.createReply(thread, op, "Test post 1", Set.of());
basicForumService.createReply(thread, commenter, "Test post 2", Set.of());
setLoggedInAs(op);
assertThrows(IllegalArgumentException.class, () -> basicForumService.deleteThread(thread));
}
}

@ -1,7 +1,9 @@
services:
oauth2:
container_name: scipro-dev-oauth2
build: https://gitea.dsv.su.se/DMC/oauth2-authorization-server.git#1d469c73468d00be5430dac01a7ab84f11ed471a
build:
context: https://github.com/dsv-su/toker.git
dockerfile: embedded.Dockerfile
restart: on-failure
ports:
- '59733:8080'
@ -9,8 +11,12 @@ services:
- CLIENT_ID=get-token
- CLIENT_SECRET=get-token-secret
- CLIENT_REDIRECT_URI=http://localhost:59732/
- RESOURCE_SERVER_ID=scipro-api-client
- RESOURCE_SERVER_SECRET=scipro-api-secret
oauth2-wicket:
build: https://gitea.dsv.su.se/DMC/oauth2-authorization-server.git#1d469c73468d00be5430dac01a7ab84f11ed471a
build:
context: https://github.com/dsv-su/toker.git
dockerfile: embedded.Dockerfile
restart: on-failure
ports:
- '59734:8080'
@ -18,4 +24,3 @@ services:
- CLIENT_ID=scipro
- CLIENT_SECRET=s3cr3t
- CLIENT_REDIRECT_URI=http://localhost:8080/login/oauth2/code/scipro
- CLIENT_SCOPES=openid

@ -1,53 +0,0 @@
package se.su.dsv.scipro.testdata.populators;
import java.time.LocalDate;
import java.util.Date;
import org.springframework.stereotype.Service;
import se.su.dsv.scipro.finalseminar.FinalSeminar;
import se.su.dsv.scipro.finalseminar.FinalSeminarService;
import se.su.dsv.scipro.project.Project;
import se.su.dsv.scipro.project.ProjectService;
import se.su.dsv.scipro.system.Language;
import se.su.dsv.scipro.system.User;
import se.su.dsv.scipro.testdata.BaseData;
import se.su.dsv.scipro.testdata.Factory;
import se.su.dsv.scipro.testdata.TestDataPopulator;
@Service
public class ProjectLevelTitleCredits implements TestDataPopulator {
private final ProjectService projectService;
private final FinalSeminarService finalSeminarService;
public ProjectLevelTitleCredits(ProjectService projectService, FinalSeminarService finalSeminarService) {
this.projectService = projectService;
this.finalSeminarService = finalSeminarService;
}
@Override
public void populate(BaseData baseData, Factory factory) {
// Participants
User author = factory.createAuthor("Beata");
User headSupervisor = factory.createSupervisor("Elsabet");
// Project
Project project = new Project();
project.setProjectType(baseData.bachelor());
project.setTitle("A bachelor thesis");
project.setCredits(15);
project.setResearchArea(baseData.researchArea().researchArea());
project.setHeadSupervisor(headSupervisor);
project.addProjectParticipant(author);
project.setStartDate(LocalDate.now().minusDays(1));
projectService.save(project);
// Seminar
FinalSeminar finalSeminar = new FinalSeminar();
finalSeminar.setProject(project);
finalSeminar.setStartDate(new Date());
finalSeminar.setRoom("Cyber Space");
finalSeminar.setPresentationLanguage(Language.SWEDISH);
finalSeminarService.save(finalSeminar);
}
}

@ -2,11 +2,7 @@
<html xmlns:wicket="http://wicket.apache.org">
<body>
<wicket:panel>
<h3>Final seminar for project:
<span wicket:id="projectTitle">[Title]</span> (<span wicket:id="projectType"></span><wicket:enclosure>,
<span wicket:id="credits">30</span> hec</wicket:enclosure>)
</h3>
<h3>Final seminar for project: <span wicket:id="projectTitle">[Title]</span></h3>
<br>
<div class="row">

@ -1,7 +1,7 @@
package se.su.dsv.scipro.finalseminar;
import jakarta.inject.Inject;
import java.util.Date;
import java.util.*;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.link.ExternalLink;
@ -18,8 +18,6 @@ import se.su.dsv.scipro.system.User;
public class SeminarPanel extends Panel {
public static final String PROJECT_TITLE = "projectTitle";
static final String PROJECT_TYPE = "projectType";
static final String CREDITS = "credits";
static final String CANCELLED = "cancelled";
static final String CRUD = "crud";
static final String CRUD_NOT_ALLOWED = "noCrud";
@ -71,16 +69,6 @@ public class SeminarPanel extends Panel {
);
add(new Label(PROJECT_TITLE, seminar.map(FinalSeminar::getProject).map(Project::getTitle)));
add(new Label(PROJECT_TYPE, seminar.map(FinalSeminar::getProject).map(Project::getProjectTypeName)));
add(
new Label(CREDITS, seminar.map(FinalSeminar::getProject).map(Project::getCredits)) {
@Override
protected void onConfigure() {
super.onConfigure();
setVisible(seminar.getObject().getProject().getCredits() > 0);
}
}
);
add(new ScheduleFinalSeminarPanel("schedule", getProject()));
add(new SeminarCRUDPanel(CRUD, seminar));

@ -6,8 +6,23 @@ import se.su.dsv.scipro.forum.dataobjects.ForumPost;
import se.su.dsv.scipro.system.User;
public interface ForumThread<A> extends IClusterable {
List<ForumPost> getPosts(A a);
/**
* @param a the context of the thread
* @return the initial post of the thread
*/
Optional<ForumPost> getInitialPost(A a);
List<ForumPost> getReplies(A a);
ForumPost reply(A a, User poster, String content, Set<Attachment> attachments);
String getSubject(A a);
void setRead(User user, A a);
boolean canDelete(A a, ForumPost post);
void deletePost(A a, ForumPost post);
boolean canDeleteThread(A a);
void deleteThread(A a);
}

@ -40,6 +40,8 @@ public class ProjectViewForumThreadPage
@Inject
private ProjectFileService projectFileService;
private final IModel<ProjectThread> threadModel;
public ProjectViewForumThreadPage(PageParameters pp) {
super(pp);
StringValue value = pp.get(PageParameterKeys.MAP.get(ForumThread.class));
@ -63,7 +65,7 @@ public class ProjectViewForumThreadPage
basicForumService.setThreadRead(SciProSession.get().getUser(), thread.getForumThread(), true);
final Long finalThreadId = threadId;
IModel<ProjectThread> threadModel = new LoadableDetachableModel<>() {
threadModel = new LoadableDetachableModel<>() {
@Override
protected ProjectThread load() {
return projectForumService.findOne(finalThreadId);
@ -79,5 +81,16 @@ public class ProjectViewForumThreadPage
);
}
@Override
protected void onConfigure() {
super.onConfigure();
// Check for thread deletion
if (threadModel.getObject() == null) {
PageParameters pageParameters = ProjectThreadedForumPage.getPageParameters(getActiveProject());
throw new RestartResponseException(ProjectThreadedForumPage.class, pageParameters);
}
}
static final String FORUM_THREAD = "forumThread";
}

@ -2,6 +2,7 @@ package se.su.dsv.scipro.forum.pages.threaded;
import jakarta.inject.Inject;
import org.apache.wicket.Page;
import org.apache.wicket.RestartResponseException;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.request.mapper.parameter.PageParameters;
@ -40,6 +41,8 @@ public class SupervisorViewForumThreadPage
@Inject
private ProjectFileService projectFileService;
private final IModel<ProjectThread> threadModel;
public SupervisorViewForumThreadPage(PageParameters pp) {
super(pp);
StringValue value = pp.get(PageParameterKeys.MAP.get(ForumThread.class));
@ -59,7 +62,7 @@ public class SupervisorViewForumThreadPage
basicForumService.setThreadRead(SciProSession.get().getUser(), thread.getForumThread(), true);
final Long finalThreadId = threadId;
final IModel<ProjectThread> threadModel = new LoadableDetachableModel<>() {
threadModel = new LoadableDetachableModel<>() {
@Override
protected ProjectThread load() {
return projectForumService.findOne(finalThreadId);
@ -72,6 +75,14 @@ public class SupervisorViewForumThreadPage
new ProjectForumThread(projectForumService),
projectModel
) {
@Override
protected void onThreadDeleted() {
setResponsePage(
SupervisorForumBasePage.class,
SupervisorForumBasePage.getPageParameters(projectModel.getObject())
);
}
@Override
protected Class<? extends Page> getForumPage() {
return SupervisorForumBasePage.class;
@ -80,5 +91,16 @@ public class SupervisorViewForumThreadPage
);
}
@Override
protected void onConfigure() {
super.onConfigure();
// Check for thread deletion
if (threadModel.getObject() == null) {
PageParameters pageParameters = SupervisorThreadedForumPage.getPageParameters(projectModel.getObject());
throw new RestartResponseException(SupervisorThreadedForumPage.class, pageParameters);
}
}
static final String FORUM_THREAD = "forumThread";
}

@ -5,12 +5,12 @@
<title></title>
</head>
<body>
<wicket:panel>
<wicket:border>
<div class="messageWrap">
<div class="forumBlueBackground d-flex justify-content-between">
<!-- DATE ROW-->
<span wicket:id="dateCreated"></span>
<button wicket:id="delete" class="btn btn-sm btn-outline-danger ms-auto">Delete</button>
<wicket:body/>
</div>
<div class="forumGrayBackground">
<div class="vertAlign">
@ -29,6 +29,6 @@
</wicket:enclosure>
</div>
</div>
</wicket:panel>
</wicket:border>
</body>
</html>

@ -1,10 +1,8 @@
package se.su.dsv.scipro.forum.panels.threaded;
import jakarta.inject.Inject;
import org.apache.wicket.Component;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.link.Link;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.markup.html.border.Border;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LambdaModel;
import se.su.dsv.scipro.components.DateLabel;
@ -13,26 +11,23 @@ import se.su.dsv.scipro.components.ListAdapterModel;
import se.su.dsv.scipro.components.SmarterLinkMultiLineLabel;
import se.su.dsv.scipro.data.enums.DateStyle;
import se.su.dsv.scipro.file.FileReference;
import se.su.dsv.scipro.forum.BasicForumService;
import se.su.dsv.scipro.forum.dataobjects.ForumPost;
import se.su.dsv.scipro.profile.UserLinkPanel;
import se.su.dsv.scipro.repository.panels.ViewAttachmentPanel;
import se.su.dsv.scipro.session.SciProSession;
public class ForumPostPanel extends Panel {
public class ForumPostPanel extends Border {
public static final String POSTED_BY = "postedBy";
public static final String DATE_CREATED = "dateCreated";
public static final String CONTENT = "content";
public static final String ATTACHMENT = "attachment";
@Inject
private BasicForumService basicForumService;
public ForumPostPanel(String id, final IModel<ForumPost> model) {
super(id);
add(new UserLinkPanel(POSTED_BY, LambdaModel.of(model, ForumPost::getPostedBy, ForumPost::setPostedBy)));
add(
super(id, model);
addToBorder(
new UserLinkPanel(POSTED_BY, LambdaModel.of(model, ForumPost::getPostedBy, ForumPost::setPostedBy))
);
addToBorder(
new WebMarkupContainer("postedBySystem") {
@Override
protected void onConfigure() {
@ -41,18 +36,18 @@ public class ForumPostPanel extends Panel {
}
}
);
add(
addToBorder(
new DateLabel(
DATE_CREATED,
LambdaModel.of(model, ForumPost::getDateCreated, ForumPost::setDateCreated),
DateStyle.DATETIME
)
);
add(
addToBorder(
new SmarterLinkMultiLineLabel(CONTENT, LambdaModel.of(model, ForumPost::getContent, ForumPost::setContent))
);
add(
addToBorder(
new DisplayMultiplesPanel<>(
ATTACHMENT,
new ListAdapterModel<>(LambdaModel.of(model, ForumPost::getAttachments, ForumPost::setAttachments))
@ -69,28 +64,5 @@ public class ForumPostPanel extends Panel {
}
}
);
add(
new Link<>("delete", model) {
@Override
public void onClick() {
ForumPost post = getModelObject();
basicForumService.deletePost(post);
onPostDeleted();
}
@Override
protected void onConfigure() {
super.onConfigure();
setVisible(allowDeletion() && basicForumService.canDelete(getModelObject()));
}
}
);
}
protected boolean allowDeletion() {
return false;
}
protected void onPostDeleted() {}
}

@ -17,8 +17,17 @@ public class ProjectForumThread implements ForumThread<ProjectThread> {
}
@Override
public List<ForumPost> getPosts(final ProjectThread projectThread) {
return projectForumService.getPosts(projectThread);
public Optional<ForumPost> getInitialPost(ProjectThread projectThread) {
return projectForumService.getPosts(projectThread).stream().findFirst();
}
@Override
public List<ForumPost> getReplies(final ProjectThread projectThread) {
List<ForumPost> posts = projectForumService.getPosts(projectThread);
if (posts.size() <= 1) {
return List.of();
}
return posts.subList(1, posts.size());
}
@Override
@ -40,4 +49,24 @@ public class ProjectForumThread implements ForumThread<ProjectThread> {
public void setRead(User user, ProjectThread thread) {
projectForumService.markRead(user, thread);
}
@Override
public boolean canDelete(ProjectThread projectThread, ForumPost post) {
return projectForumService.canDelete(projectThread, post);
}
@Override
public void deletePost(ProjectThread projectThread, ForumPost post) {
projectForumService.deletePost(projectThread, post);
}
@Override
public boolean canDeleteThread(ProjectThread projectThread) {
return projectForumService.canDelete(projectThread);
}
@Override
public void deleteThread(ProjectThread projectThread) {
projectForumService.deleteThread(projectThread);
}
}

@ -19,8 +19,13 @@
<div wicket:id="topReplyPanel"></div>
<!-- POST LIST-->
<div class="d-flex flex-column-reverse">
<wicket:container wicket:id="initialPost">
<button wicket:id="delete" class="btn btn-sm btn-outline-danger ms-auto">Delete</button>
</wicket:container>
<wicket:container wicket:id="postList">
<wicket:container wicket:id="post"/>
<wicket:container wicket:id="post">
<button wicket:id="delete" class="btn btn-sm btn-outline-danger ms-auto">Delete</button>
</wicket:container>
</wicket:container>
</div>
</div>

@ -1,5 +1,6 @@
package se.su.dsv.scipro.forum.panels.threaded;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.apache.wicket.Component;
@ -7,11 +8,13 @@ import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.link.Link;
import org.apache.wicket.markup.html.list.ListItem;
import org.apache.wicket.markup.html.list.ListView;
import org.apache.wicket.markup.html.panel.GenericPanel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LoadableDetachableModel;
import se.su.dsv.scipro.components.OrNullModel;
import se.su.dsv.scipro.forum.ForumThread;
import se.su.dsv.scipro.forum.dataobjects.ForumPost;
import se.su.dsv.scipro.session.SciProSession;
@ -20,10 +23,9 @@ public class ViewForumThreadPanel<A> extends GenericPanel<A> {
private final ForumThread<A> forumThread;
private final WebMarkupContainer wrapper;
private SubmitForumReplyPanel topReplyPanel;
private SubmitForumReplyPanel<A> topReplyPanel;
private AjaxLink<Void> topReplyLink;
@SuppressWarnings("unchecked")
public ViewForumThreadPanel(String id, final IModel<A> model, final ForumThread<A> forumThread) {
super(id, model);
this.forumThread = forumThread;
@ -54,30 +56,66 @@ public class ViewForumThreadPanel<A> extends GenericPanel<A> {
}
private void addPostList() {
ForumPostPanel initialPostPanel = new ForumPostPanel(
"initialPost",
new OrNullModel<>(getModel().map(forumThread::getInitialPost))
) {
@Override
protected void onConfigure() {
super.onConfigure();
setVisible(getDefaultModelObject() != null);
}
};
initialPostPanel.add(
new Link<>("delete", getModel()) {
@Override
public void onClick() {
forumThread.deleteThread(getModelObject());
onThreadDeleted();
}
@Override
protected void onConfigure() {
super.onConfigure();
setVisible(forumThread.canDeleteThread(getModelObject()));
}
}
);
wrapper.add(initialPostPanel);
wrapper.add(
new ListView<>(POST_LIST, new PostProvider()) {
@Override
protected void populateItem(ListItem<ForumPost> item) {
ListView<ForumPost> listView = this;
item.add(
new ForumPostPanel(POST, item.getModel()) {
ForumPostPanel forumPostPanel = new ForumPostPanel(POST, item.getModel());
forumPostPanel.add(
new Link<>("delete", item.getModel()) {
@Override
protected boolean allowDeletion() {
return true;
public void onClick() {
ForumPost post = getModelObject();
forumThread.deletePost(ViewForumThreadPanel.this.getModelObject(), post);
listView.detach();
}
@Override
protected void onPostDeleted() {
// Refresh the list of posts
listView.detach();
protected void onConfigure() {
super.onConfigure();
setVisible(
forumThread.canDelete(ViewForumThreadPanel.this.getModelObject(), getModelObject())
);
}
}
);
item.add(forumPostPanel);
}
}
);
}
protected void onThreadDeleted() {
// callback when a thread is deleted
}
private void addReplyButtons() {
topReplyLink = new AjaxLink<>(TOP_REPLY_LINK) {
@Override
@ -118,9 +156,10 @@ public class ViewForumThreadPanel<A> extends GenericPanel<A> {
@Override
protected List<ForumPost> load() {
List<ForumPost> posts = forumThread.getPosts(getModelObject());
posts.sort(Comparator.comparing(ForumPost::getDateCreated));
return posts;
List<ForumPost> posts = forumThread.getReplies(getModelObject());
ArrayList<ForumPost> sortedPosts = new ArrayList<>(posts);
sortedPosts.sort(Comparator.comparing(ForumPost::getDateCreated));
return sortedPosts;
}
}
}

@ -29,6 +29,11 @@ public class ViewProjectForumThreadPanel extends ViewForumThreadPanel<ProjectThr
return new BookmarkablePageLink<Void>(id, getForumPage(), getProjectParameters(projectModel.getObject()));
}
@Override
protected void onThreadDeleted() {
setResponsePage(getForumPage(), getProjectParameters(projectModel.getObject()));
}
protected Class<? extends Page> getForumPage() {
return ProjectForumBasePage.class;
}

@ -17,8 +17,17 @@ public class GroupForumThread implements ForumThread<GroupThread> {
}
@Override
public List<ForumPost> getPosts(final GroupThread groupThread) {
return groupForumService.getPosts(groupThread);
public Optional<ForumPost> getInitialPost(GroupThread groupThread) {
return groupForumService.getPosts(groupThread).stream().findFirst();
}
@Override
public List<ForumPost> getReplies(final GroupThread groupThread) {
List<ForumPost> posts = groupForumService.getPosts(groupThread);
if (posts.size() <= 1) {
return List.of();
}
return posts.subList(1, posts.size());
}
@Override
@ -40,4 +49,24 @@ public class GroupForumThread implements ForumThread<GroupThread> {
public void setRead(User user, GroupThread groupThread) {
groupForumService.markRead(user, groupThread);
}
@Override
public boolean canDelete(GroupThread groupThread, ForumPost post) {
return groupForumService.canDelete(groupThread, post);
}
@Override
public void deletePost(GroupThread groupThread, ForumPost post) {
groupForumService.deletePost(groupThread, post);
}
@Override
public boolean canDeleteThread(GroupThread groupThread) {
return groupForumService.canDeleteThread(groupThread);
}
@Override
public void deleteThread(GroupThread groupThread) {
groupForumService.deleteThread(groupThread);
}
}

@ -56,6 +56,13 @@ public class ViewThreadPage extends AbstractAuthorGroupPage implements MenuHighl
);
return new BookmarkablePageLink<Void>(id, AuthorGroupPage.class, pageParameters);
}
@Override
protected void onThreadDeleted() {
PageParameters pageParameters = new PageParameters();
pageParameters.set(PageParameterKeys.MAP.get(Group.class), getGroup().getObject().getId());
setResponsePage(AuthorGroupPage.class, pageParameters);
}
}
);
}

@ -340,7 +340,7 @@ public class NotificationLandingPage extends WebPage {
pp.set(PageParameterKeys.MAP.get(Project.class), project.getId());
switch (projectForumEvent.getEvent()) {
case NEW_FORUM_POST, NEW_FORUM_POST_COMMENT:
case NEW_FORUM_POST, NEW_FORUM_POST_COMMENT, FORUM_POST_DELETED, FORUM_THREAD_DELETED:
roleSplit(currentUser, project, ProjectForumBasePage.class, SupervisorForumBasePage.class, pp);
break;
case NEW_REVIEWER_INTERACTION:

@ -18,7 +18,12 @@ public class ReviewerInteractionForumThread implements ForumThread<Project>, ICl
}
@Override
public List<ForumPost> getPosts(final Project project) {
public Optional<ForumPost> getInitialPost(Project project) {
return Optional.empty();
}
@Override
public List<ForumPost> getReplies(final Project project) {
return reviewerInteractionService.getPosts(project);
}
@ -41,4 +46,24 @@ public class ReviewerInteractionForumThread implements ForumThread<Project>, ICl
public void setRead(User user, Project project) {
reviewerInteractionService.markAllRead(user, project);
}
@Override
public boolean canDelete(Project project, ForumPost post) {
return false;
}
@Override
public void deletePost(Project project, ForumPost post) {
throw new UnsupportedOperationException("Can not delete posts in reviewer interaction");
}
@Override
public boolean canDeleteThread(Project project) {
return false;
}
@Override
public void deleteThread(Project project) {
throw new UnsupportedOperationException("Can not delete the entire reviewer interaction");
}
}

@ -8,7 +8,7 @@
<wicket:panel>
<div class="d-flex flex-column-reverse pt-1">
<wicket:container wicket:id="events">
<wicket:container wicket:id="event"/>
<wicket:container wicket:id="event"></wicket:container>
</wicket:container>
</div>
</wicket:panel>

@ -49,6 +49,14 @@ public class SupervisorViewGroupThreadPage
);
return new BookmarkablePageLink<Void>(id, SupervisorGroupPage.class, pageParameters);
}
@Override
protected void onThreadDeleted() {
setResponsePage(
SupervisorGroupPage.class,
SupervisorGroupPage.getPageParameters(groupModel.getObject())
);
}
}
);
}

@ -71,6 +71,8 @@ ProjectEvent.REFLECTION_IMPROVEMENTS_SUBMITTED = Reflection improvements submitt
ProjectForumEvent.NEW_FORUM_POST = Forum thread created.
ProjectForumEvent.NEW_FORUM_POST_COMMENT = Comment posted in forum thread.
ProjectForumEvent.NEW_REVIEWER_INTERACTION = Comment posted in supervisor - reviewer communication.
ProjectForumEvent.FORUM_THREAD_DELETED = Forum thread deleted.
ProjectForumEvent.FORUM_POST_DELETED = Forum post deleted.
SeminarEvent.CREATED = Final seminar created. (with date, time, place, room specified by head supervisor; authors can now upload final seminar thesis; other authors can now oppose and be active participants)
SeminarEvent.ROOM_CHANGED = Room changed.

@ -81,4 +81,16 @@ public class ProjectViewForumThreadPageTest extends PageTest {
page = tester.startPage(ProjectViewForumThreadPage.class, getPageParameters());
tester.assertRenderedPage(NotFoundPage.class);
}
@Test
public void handle_deleted_thread_while_on_page() {
// First call there is a thread, gets deleted while on the page
when(projectForumService.findOne(isA(Long.class))).thenReturn(thread);
ProjectViewForumThreadPage page = tester.startPage(ProjectViewForumThreadPage.class, getPageParameters());
when(projectForumService.findOne(isA(Long.class))).thenReturn(null);
tester.startPage(page);
tester.assertRenderedPage(ProjectThreadedForumPage.class);
}
}

@ -69,4 +69,16 @@ public class SupervisorViewForumThreadPageTest extends PageTest {
page = tester.startPage(SupervisorViewForumThreadPage.class, getPageParameters());
tester.assertRenderedPage(NotFoundPage.class);
}
@Test
public void handle_deleted_thread_while_on_page() {
// First call there is a thread, gets deleted while on the page
when(projectForumService.findOne(isA(Long.class))).thenReturn(thread);
SupervisorViewForumThreadPage page = tester.startPage(SupervisorViewForumThreadPage.class, getPageParameters());
when(projectForumService.findOne(isA(Long.class))).thenReturn(null);
tester.startPage(page);
tester.assertRenderedPage(SupervisorThreadedForumPage.class);
}
}

@ -55,7 +55,7 @@ public class ApiConfig {
return http
.securityMatcher(mvc.pattern("/**"))
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.oauth2ResourceServer(oauth2 -> oauth2.opaqueToken(Customizer.withDefaults()))
.build();
}

@ -0,0 +1,42 @@
package se.su.dsv.scipro.war;
import java.net.URI;
import java.util.Collections;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
public class TokenIntrospectionRequestEntityConverter implements Converter<OAuth2UserRequest, RequestEntity<?>> {
private static final MediaType FORM_URL_ENCODED = MediaType.valueOf(
MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8"
);
@Override
public RequestEntity<?> convert(OAuth2UserRequest userRequest) {
ClientRegistration clientRegistration = userRequest.getClientRegistration();
URI uri = UriComponentsBuilder.fromUriString(
clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri()
)
.build()
.toUri();
HttpHeaders headers = new HttpHeaders();
headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret());
headers.setAccept(Collections.singletonList(MediaType.ALL));
headers.setContentType(FORM_URL_ENCODED);
MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
formParameters.add(OAuth2ParameterNames.TOKEN, userRequest.getAccessToken().getTokenValue());
return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
}
}

@ -14,6 +14,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.web.SecurityFilterChain;
import se.su.dsv.scipro.SciProApplication;
import se.su.dsv.scipro.crosscutting.ForwardPhase2Feedback;
@ -67,6 +68,21 @@ public class WicketConfiguration {
return http.build();
}
// Stop gap measure to switch to Token Introspection instead of OIDC UserInfo
// endpoint. This is necessary because the UserInfo endpoint will in soon require
// the "openid" scope, which is not granted to our clients. Unfortunately we can't
// request the scope because that makes Spring require an id token in the token
// exchange which is not granted at the moment.
//
// Once a new authorization server is in place we can remove this bean and use
// straight up id tokens with "openid" scope.
@Bean
public DefaultOAuth2UserService defaultOAuth2UserService() {
DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
defaultOAuth2UserService.setRequestEntityConverter(new TokenIntrospectionRequestEntityConverter());
return defaultOAuth2UserService;
}
@Bean
public CurrentUserFromSpringSecurity currentUserFromSpringSecurity(
UserService userService,

@ -17,13 +17,17 @@ springdoc.swagger-ui.path=/swagger
springdoc.swagger-ui.persist-authorization=true
# These will be overwritten by configuration in the environment of servers it is deployed to
spring.security.oauth2.resourceserver.jwt.issuer-uri=${OAUTH2_ISSUER_URI:http://localhost:59733}
spring.security.oauth2.resourceserver.opaquetoken.client-id=${OAUTH2_RESOURCE_SERVER_ID:scipro-api-client}
spring.security.oauth2.resourceserver.opaquetoken.client-secret=${OAUTH2_RESOURCE_SERVER_SECRET:scipro-api-secret}
spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=${OAUTH2_RESOURCE_SERVER_INTROSPECTION_URI:http://localhost:59733/introspect}
# Log in via local OAuth 2 authorization server
spring.security.oauth2.client.provider.docker.issuer-uri=${OAUTH2_ISSUER_URI:http://localhost:59734}
spring.security.oauth2.client.provider.docker.user-info-uri=${OAUTH2_USER_INFO_URI:http://localhost:59734/introspect}
spring.security.oauth2.client.provider.docker.user-name-attribute=sub
spring.security.oauth2.client.provider.docker.token-uri=${OAUTH2_TOKEN_URI:http://localhost:59734/exchange}
spring.security.oauth2.client.provider.docker.authorization-uri=${OAUTH2_AUTHORIZATION_URI:http://localhost:59734/authorize}
spring.security.oauth2.client.registration.scipro.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.scipro.provider=docker
spring.security.oauth2.client.registration.scipro.client-id=${OAUTH2_CLIENT_ID:scipro}
spring.security.oauth2.client.registration.scipro.client-secret=${OAUTH2_CLIENT_SECRET:s3cr3t}
spring.security.oauth2.client.registration.scipro.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.scipro.scope=openid