diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b84f89c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive", + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/Stardust.db b/Stardust.db new file mode 100644 index 0000000..3ccf08e Binary files /dev/null and b/Stardust.db differ diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..fd004db --- /dev/null +++ b/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 JSESSIONID DB5849AC053301B7D9FEDABF995B3D34 diff --git a/java/Dockerfile b/java/Dockerfile new file mode 100644 index 0000000..e4b327e --- /dev/null +++ b/java/Dockerfile @@ -0,0 +1,12 @@ +# Build stage +FROM maven:3.9-eclipse-temurin-17 AS builder +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn clean package -q -DskipTests +# Runtime stage +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=builder /app/target/Stardust-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +CMD ["java", "-jar", "app.jar"] diff --git a/java/README.md b/java/README.md index a674827..0f7ead9 100644 --- a/java/README.md +++ b/java/README.md @@ -54,3 +54,25 @@ By default, the app runs on: - user profile/settings pages - media embedding (image/video links) - moderation tools and role-based controls + +to run it- cd java +mvn spring-boot:run + +curl -s -b cookies.txt http://localhost:5000/viewpost?post=1 | grep -o "action_react" | head -1 + +curl -s -o /dev/null -w "%{http_code}" -X POST \ + -b cookies.txt -c cookies.txt \ + -d "postId=1&type=LIKE" \ + http://localhost:5000/action_react + +Reaction.java +→ +ReactionRepository +→ +ForumService +→ +ForumController +→ +viewpost.html + +lsof -ti :5000 | xargs kill -9 2>/dev/null && mvn spring-boot:run \ No newline at end of file diff --git a/java/docker-compose.yml b/java/docker-compose.yml new file mode 100644 index 0000000..7bb46aa --- /dev/null +++ b/java/docker-compose.yml @@ -0,0 +1,9 @@ +services: + app: + build: . + ports: + - "7071:8080" + environment: + SPRING_DATASOURCE_URL: "jdbc:postgresql://xo.zipcode.rocks:9088/circus" + SPRING_DATASOURCE_USERNAME: sunflower_user + SPRING_DATASOURCE_PASSWORD: zipmusic diff --git a/java/pom.xml b/java/pom.xml index d35d08a..fbf8bef 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -1,13 +1,13 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.springframework.boot spring-boot-starter-parent - 3.2.5 + 3.5.13 @@ -43,14 +43,9 @@ thymeleaf-extras-springsecurity6 - org.xerial - sqlite-jdbc - 3.45.3.0 - - - org.hibernate.orm - hibernate-community-dialects - 6.4.4.Final + org.postgresql + postgresql + runtime org.springframework.boot @@ -62,6 +57,36 @@ spring-security-test test + + org.commonmark + commonmark + 0.22.0 + + + org.commonmark + commonmark-ext-gfm-tables + 0.22.0 + + + org.commonmark + commonmark-ext-gfm-strikethrough + 0.22.0 + + + org.commonmark + commonmark-ext-task-list-items + 0.22.0 + + + org.commonmark + commonmark-ext-autolink + 0.22.0 + + + com.googlecode.owasp-java-html-sanitizer + owasp-java-html-sanitizer + 20240325.1 + diff --git a/java/src/main/java/com/zipcode/stardust/config/DataInitializer.java b/java/src/main/java/com/zipcode/stardust/config/DataInitializer.java index 41c04ae..04c3810 100644 --- a/java/src/main/java/com/zipcode/stardust/config/DataInitializer.java +++ b/java/src/main/java/com/zipcode/stardust/config/DataInitializer.java @@ -1,7 +1,9 @@ package com.zipcode.stardust.config; import com.zipcode.stardust.model.Subforum; +import com.zipcode.stardust.model.User; import com.zipcode.stardust.repository.SubforumRepository; +import com.zipcode.stardust.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -13,6 +15,9 @@ public class DataInitializer implements ApplicationRunner { @Autowired private SubforumRepository subforumRepository; + @Autowired + private UserRepository userRepository; + @Override public void run(ApplicationArguments args) { if (subforumRepository.count() == 0) { @@ -36,5 +41,13 @@ public void run(ApplicationArguments args) { "Discuss other things here", null); subforumRepository.save(other); } + + // Promote any existing accounts that belong to privileged usernames + for (User user : userRepository.findAll()) { + if (User.isPrivilegedUsername(user.getUsername()) && !user.isAdmin()) { + user.setAdmin(true); + userRepository.save(user); + } + } } } diff --git a/java/src/main/java/com/zipcode/stardust/config/SecurityConfig.java b/java/src/main/java/com/zipcode/stardust/config/SecurityConfig.java index 68a847c..a0cf24a 100644 --- a/java/src/main/java/com/zipcode/stardust/config/SecurityConfig.java +++ b/java/src/main/java/com/zipcode/stardust/config/SecurityConfig.java @@ -1,9 +1,9 @@ package com.zipcode.stardust.config; -import com.zipcode.stardust.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.UserDetailsService; @@ -12,8 +12,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import com.zipcode.stardust.repository.UserRepository; + @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { @Autowired @@ -35,10 +38,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/subforum", "/loginform", "/viewpost", "/action_login", - "/action_createaccount", "/static/**", "/style.css").permitAll() - .requestMatchers("/addpost", "/action_post", "/action_comment") + "/action_createaccount", "/static/**", "/style.css", + "/uploads/**").permitAll() + .requestMatchers("/addpost", "/action_post", "/action_comment", "/upload") .authenticated() - .anyRequest().permitAll() + .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/loginform") diff --git a/java/src/main/java/com/zipcode/stardust/config/WebConfig.java b/java/src/main/java/com/zipcode/stardust/config/WebConfig.java new file mode 100644 index 0000000..c656990 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.zipcode.stardust.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${upload.dir:uploads}") + private String uploadDir; + + @Override + public void addResourceHandlers(@NonNull ResourceHandlerRegistry registry) { + registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:" + uploadDir + "/"); + } +} diff --git a/java/src/main/java/com/zipcode/stardust/controller/ForumController.java b/java/src/main/java/com/zipcode/stardust/controller/ForumController.java index 73f2019..1f96fa1 100644 --- a/java/src/main/java/com/zipcode/stardust/controller/ForumController.java +++ b/java/src/main/java/com/zipcode/stardust/controller/ForumController.java @@ -1,19 +1,34 @@ package com.zipcode.stardust.controller; -import com.zipcode.stardust.model.*; -import com.zipcode.stardust.repository.*; -import com.zipcode.stardust.service.ForumService; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import com.zipcode.stardust.model.Comment; +import com.zipcode.stardust.model.Post; +import com.zipcode.stardust.model.Reaction; +import com.zipcode.stardust.model.Subforum; +import com.zipcode.stardust.model.User; +import com.zipcode.stardust.repository.CommentRepository; +import com.zipcode.stardust.repository.MediaEmbedRepository; +import com.zipcode.stardust.repository.PostRepository; +import com.zipcode.stardust.repository.SubforumRepository; +import com.zipcode.stardust.repository.UserRepository; +import com.zipcode.stardust.service.CommonAttributesHelper; +import com.zipcode.stardust.service.ForumService; @Controller public class ForumController { @@ -24,30 +39,15 @@ public class ForumController { @Autowired private UserRepository userRepository; @Autowired private PasswordEncoder passwordEncoder; @Autowired private ForumService forumService; - - @Value("${site.name:Schooner}") - private String siteName; - - @Value("${site.description:a schooner forum}") - private String siteDescription; + @Autowired private MediaEmbedRepository mediaEmbedRepository; + @Autowired private CommonAttributesHelper helper; private User getCurrentUser(Authentication auth) { - if (auth == null || !auth.isAuthenticated() || - "anonymousUser".equals(auth.getPrincipal())) { - return null; - } - return (User) auth.getPrincipal(); + return helper.getCurrentUser(auth); } private void addCommonAttributes(Model model, Authentication auth) { - model.addAttribute("siteName", siteName); - model.addAttribute("siteDescription", siteDescription); - if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { - model.addAttribute("currentUser", auth.getName()); - model.addAttribute("isLoggedIn", true); - } else { - model.addAttribute("isLoggedIn", false); - } + helper.addCommonAttributes(model, auth); } @GetMapping("/") @@ -59,7 +59,7 @@ public String index(Model model, Authentication auth) { } @GetMapping("/subforum") - public String subforum(@RequestParam Long sub, Model model, Authentication auth) { + public String subforum(@RequestParam long sub, Model model, Authentication auth) { addCommonAttributes(model, auth); Optional opt = subforumRepository.findById(sub); if (opt.isEmpty()) return "redirect:/"; @@ -119,7 +119,7 @@ public String createAccount(@RequestParam String username, } @GetMapping("/addpost") - public String addPostForm(@RequestParam Long sub, Model model, Authentication auth) { + public String addPostForm(@RequestParam long sub, Model model, Authentication auth) { addCommonAttributes(model, auth); Optional opt = subforumRepository.findById(sub); if (opt.isEmpty()) return "redirect:/"; @@ -129,7 +129,7 @@ public String addPostForm(@RequestParam Long sub, Model model, Authentication au } @PostMapping("/action_post") - public String createPost(@RequestParam Long sub, + public String createPost(@RequestParam long sub, @RequestParam String title, @RequestParam String content, Model model, Authentication auth) { @@ -163,22 +163,47 @@ public String createPost(@RequestParam Long sub, } @GetMapping("/viewpost") - public String viewPost(@RequestParam Long post, Model model, Authentication auth) { + public String viewPost(@RequestParam long post, Model model, Authentication auth) { addCommonAttributes(model, auth); Optional opt = postRepository.findById(post); if (opt.isEmpty()) return "redirect:/"; Post p = opt.get(); List comments = commentRepository.findByPostOrderByPostdateAsc(p); String breadcrumb = forumService.generateLinkPath(p.getSubforum().getId()); + Map commentContents = new LinkedHashMap<>(); + for (Comment c : comments) { + commentContents.put(c.getId(), forumService.renderMarkdown(c.getContent())); + } model.addAttribute("post", p); + model.addAttribute("postContent", forumService.renderMarkdown(p.getContent())); model.addAttribute("comments", comments); + model.addAttribute("commentContents", commentContents); + model.addAttribute("embeds", mediaEmbedRepository.findByPostOrderByIdAsc(p)); model.addAttribute("breadcrumb", breadcrumb); model.addAttribute("errors", new ArrayList<>()); + + // all reaction counts + model.addAttribute("likeCount", forumService.getLikeCount(p)); + model.addAttribute("dislikeCount", forumService.getDislikeCount(p)); + model.addAttribute("fireCount", forumService.getFireCount(p)); + model.addAttribute("funnyCount", forumService.getFunnyCount(p)); + model.addAttribute("sadCount", forumService.getSadCount(p)); + model.addAttribute("celebrateCount", forumService.getCelebrateCount(p)); + + // current user's reaction + User currentUser = getCurrentUser(auth); + if (currentUser != null) { + Reaction userReaction = forumService.getUserReaction(currentUser, p); + model.addAttribute("userReaction", userReaction != null ? userReaction.getType() : ""); + } else { + model.addAttribute("userReaction", ""); + } + return "viewpost"; } @PostMapping("/action_comment") - public String addComment(@RequestParam Long post, + public String addComment(@RequestParam long post, @RequestParam String content, Authentication auth) { if (auth == null || !auth.isAuthenticated()) { @@ -193,7 +218,146 @@ public String addComment(@RequestParam Long post, } @GetMapping("/action_comment") - public String addCommentGet(@RequestParam Long post) { + public String addCommentGet(@RequestParam long post) { return "redirect:/viewpost?post=" + post; } -} + + @PostMapping("/action_preview") + @ResponseBody + public String preview(@RequestBody String raw) { + return forumService.renderMarkdown(raw); + } + + @GetMapping("/settings") + public String settingsPage(Model model, Authentication auth) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + addCommonAttributes(model, auth); + model.addAttribute("profile", forumService.getUserProfile(user)); + model.addAttribute("errors", new ArrayList<>()); + model.addAttribute("success", ""); + return "settings"; + } + + @PostMapping("/action_update_bio") + public String updateBio(@RequestParam String bio, Authentication auth) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + forumService.updateBio(user, bio); + return "redirect:/settings?saved=bio"; + } + + @PostMapping("/action_update_email") + public String updateEmail(@RequestParam String email, Authentication auth, + org.springframework.web.servlet.mvc.support.RedirectAttributes ra) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + if (!forumService.updateEmail(user, email)) { + ra.addFlashAttribute("emailError", "Email is already in use or invalid."); + } else { + ra.addFlashAttribute("success", "Email updated."); + } + return "redirect:/settings"; + } + + @PostMapping("/action_update_password") + public String updatePassword(@RequestParam String currentPassword, + @RequestParam String newPassword, + Authentication auth, + org.springframework.web.servlet.mvc.support.RedirectAttributes ra) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + if (!forumService.updatePassword(user, currentPassword, newPassword, passwordEncoder)) { + ra.addFlashAttribute("passwordError", "Current password is wrong or new password is invalid (6-40 chars, alphanumeric + !@#%&)."); + } else { + ra.addFlashAttribute("success", "Password updated."); + } + return "redirect:/settings"; + } + + @PostMapping("/action_react") + public String reactToPost(@RequestParam long postId, + @RequestParam String type, + Authentication auth) { + if (auth == null || !auth.isAuthenticated()) { + return "redirect:/loginform"; + } + Optional opt = postRepository.findById(postId); + if (opt.isEmpty()) return "redirect:/"; + + User user = getCurrentUser(auth); + forumService.reactToPost(user, opt.get(), type); + + return "redirect:/viewpost?post=" + postId + "&reacted=" + type; + } + + // ── Delete post ─────────────────────────────────────────────── + @PostMapping("/action_delete_post") + public String deletePost(@RequestParam long postId, Authentication auth) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + Optional opt = postRepository.findById(postId); + if (opt.isEmpty()) return "redirect:/"; + Post post = opt.get(); + boolean isOwner = post.getUser().getUsername().equals(user.getUsername()); + if (!isOwner && !user.isAdmin()) return "redirect:/viewpost?post=" + postId; + long subId = post.getSubforum().getId(); + forumService.moderatePost(postId); + return "redirect:/subforum?sub=" + subId; + } + + // ── Delete comment ──────────────────────────────────────────── + @PostMapping("/action_delete_comment") + public String deleteComment(@RequestParam long commentId, + @RequestParam long postId, + Authentication auth) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + Optional opt = commentRepository.findById(commentId); + if (opt.isEmpty()) return "redirect:/viewpost?post=" + postId; + Comment comment = opt.get(); + boolean isOwner = comment.getUser().getUsername().equals(user.getUsername()); + if (!isOwner && !user.isAdmin()) return "redirect:/viewpost?post=" + postId; + forumService.moderateComment(commentId); + return "redirect:/viewpost?post=" + postId; + } + + // ── Edit post form ──────────────────────────────────────────── + @GetMapping("/editpost") + public String editPostForm(@RequestParam long postId, Model model, Authentication auth) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + Optional opt = postRepository.findById(postId); + if (opt.isEmpty()) return "redirect:/"; + Post post = opt.get(); + if (!post.getUser().getUsername().equals(user.getUsername())) return "redirect:/viewpost?post=" + postId; + addCommonAttributes(model, auth); + model.addAttribute("post", post); + return "editpost"; + } + + // ── Save edited post ────────────────────────────────────────── + @PostMapping("/action_edit_post") + public String saveEditPost(@RequestParam long postId, + @RequestParam String title, + @RequestParam String content, + Authentication auth) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + forumService.editPost(postId, title, content, user); + return "redirect:/viewpost?post=" + postId; + } + + // ── Save edited comment ─────────────────────────────────────── + @PostMapping("/action_edit_comment") + public String saveEditComment(@RequestParam long commentId, + @RequestParam long postId, + @RequestParam String content, + Authentication auth) { + User user = getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + boolean saved = forumService.editComment(commentId, content, user); + if (!saved) return "redirect:/viewpost?post=" + postId + "&editError=1"; + return "redirect:/viewpost?post=" + postId; + } +} \ No newline at end of file diff --git a/java/src/main/java/com/zipcode/stardust/controller/MediaEmbedController.java b/java/src/main/java/com/zipcode/stardust/controller/MediaEmbedController.java new file mode 100644 index 0000000..c9e5ce5 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/controller/MediaEmbedController.java @@ -0,0 +1,195 @@ +package com.zipcode.stardust.controller; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.zipcode.stardust.model.MediaEmbed; +import com.zipcode.stardust.model.MediaEmbed.MediaType; +import com.zipcode.stardust.model.Post; +import com.zipcode.stardust.model.User; +import com.zipcode.stardust.repository.PostRepository; +import com.zipcode.stardust.service.MediaEmbedService; + +/** + * ============================================================= + * MediaEmbedController — handles HTTP requests related to + * adding and deleting media embeds on posts. + * + * KEY CONCEPTS FOR LEARNERS + * -------------------------- + * @Controller → Spring registers this as a web controller. + * Methods return view names or "redirect:" URLs. + * + * @PostMapping → only handles HTTP POST requests (form submit). + * @RequestParam → pulls a value out of the request form/URL. + * + * RedirectAttributes → lets us pass a one-time "flash" message + * through a redirect so the user sees + * feedback on the next page. + * + * Why a separate controller? + * Keeping media logic out of ForumController means each file + * stays focused and easier to read — good team practice. + * ============================================================= + */ +@Controller +public class MediaEmbedController { + + @Autowired + private PostRepository postRepository; + + @Autowired + private MediaEmbedService mediaEmbedService; + + // ── Helper ──────────────────────────────────────────────── + + /** + * Returns the logged-in User, or null if nobody is logged in. + * We check for "anonymousUser" because Spring Security sets + * that string as the principal when no real user is present. + */ + private User getCurrentUser(Authentication auth) { + if (auth == null || !auth.isAuthenticated() || + "anonymousUser".equals(auth.getPrincipal())) { + return null; + } + return (User) auth.getPrincipal(); + } + + // ── Add embed ───────────────────────────────────────────── + + /** + * Handles the "Add Media" form that lives on the viewpost page. + * + * Flow: + * 1. Check the user is logged in. + * 2. Find the Post by id — redirect home if not found. + * 3. Validate the URL. + * 4. Auto-detect IMAGE vs VIDEO (or use what user chose). + * 5. Validate the caption. + * 6. Build a MediaEmbed and save it. + * 7. Redirect back to the post. + * + * @param postId id of the post (from hidden form field) + * @param url the media URL the user typed + * @param mediaTypeOverride optional: "IMAGE" or "VIDEO" from a dropdown + * @param caption optional caption text + * @param auth Spring Security fills this in automatically + * @param redirectAttrs used to send a one-time message through redirect + */ + @PostMapping("/action_add_embed") + public String addEmbed( + @RequestParam("post") long postId, + @RequestParam("url") String url, + @RequestParam(value = "mediaType", + required = false) String mediaTypeOverride, + @RequestParam(value = "caption", + defaultValue = "") String caption, + Authentication auth, + RedirectAttributes redirectAttrs) { + + // 1. Must be logged in + if (getCurrentUser(auth) == null) { + return "redirect:/loginform"; + } + + // 2. Post must exist + Optional optPost = postRepository.findById(postId); + if (optPost.isEmpty()) { + return "redirect:/"; + } + Post post = optPost.get(); + + // 3. Validate URL + if (!mediaEmbedService.isValidUrl(url)) { + redirectAttrs.addFlashAttribute("embedError", + "Please enter a valid URL starting with http:// or https://"); + return "redirect:/viewpost?post=" + postId; + } + + // 4. Determine media type + // If the user explicitly chose one from the dropdown, use it. + // Otherwise, let the service auto-detect from the URL. + MediaType mediaType; + if (mediaTypeOverride != null && !mediaTypeOverride.isBlank()) { + try { + mediaType = MediaType.valueOf(mediaTypeOverride.toUpperCase()); + } catch (IllegalArgumentException e) { + // Safety net: valueOf() throws if the string doesn't match an enum name + mediaType = mediaEmbedService.detectMediaType(url); + } + } else { + mediaType = mediaEmbedService.detectMediaType(url); + } + + // 5. Validate caption + if (!mediaEmbedService.isValidCaption(caption)) { + redirectAttrs.addFlashAttribute("embedError", + "Caption must be 300 characters or fewer."); + return "redirect:/viewpost?post=" + postId; + } + + // 6. Build and save + // Trim the URL to remove accidental leading/trailing spaces. + MediaEmbed embed = new MediaEmbed(url.strip(), mediaType, caption.strip(), post); + mediaEmbedService.save(embed); + + // 7. Flash a success message and go back to the post + redirectAttrs.addFlashAttribute("embedSuccess", "Media added!"); + return "redirect:/viewpost?post=" + postId; + } + + // ── Delete embed ────────────────────────────────────────── + + /** + * Lets the original post author (or an admin) remove an embed. + * + * Security check: we compare the logged-in user's username to + * the post author's username. Admins (isAdmin()) may also delete. + * + * @param embedId id of the embed to delete + * @param postId id of the post (so we can redirect back) + * @param auth injected by Spring Security + * @param redirectAttrs flash message carrier + */ + @PostMapping("/action_delete_embed") + public String deleteEmbed( + @RequestParam("embedId") long embedId, + @RequestParam("post") long postId, + Authentication auth, + RedirectAttributes redirectAttrs) { + + User currentUser = getCurrentUser(auth); + if (currentUser == null) { + return "redirect:/loginform"; + } + + // Find the post so we can check ownership + Optional optPost = postRepository.findById(postId); + if (optPost.isEmpty()) { + return "redirect:/"; + } + Post post = optPost.get(); + + // Allow deletion only if the user owns the post or is an admin + boolean isOwner = post.getUser().getUsername() + .equals(currentUser.getUsername()); + boolean isAdmin = currentUser.isAdmin(); + + if (!isOwner && !isAdmin) { + redirectAttrs.addFlashAttribute("embedError", + "You don't have permission to remove that media."); + return "redirect:/viewpost?post=" + postId; + } + + mediaEmbedService.deleteEmbed(embedId); + redirectAttrs.addFlashAttribute("embedSuccess", "Media removed."); + return "redirect:/viewpost?post=" + postId; + } +} \ No newline at end of file diff --git a/java/src/main/java/com/zipcode/stardust/controller/MessageController.java b/java/src/main/java/com/zipcode/stardust/controller/MessageController.java new file mode 100644 index 0000000..b51b311 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/controller/MessageController.java @@ -0,0 +1,182 @@ +package com.zipcode.stardust.controller; + +import com.zipcode.stardust.model.Message; +import com.zipcode.stardust.model.User; +import com.zipcode.stardust.repository.MessageRepository; +import com.zipcode.stardust.repository.UserRepository; +import com.zipcode.stardust.service.CommonAttributesHelper; +import com.zipcode.stardust.service.ForumService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Controller +public class MessageController { + + @Autowired private MessageRepository messageRepository; + @Autowired private UserRepository userRepository; + @Autowired private CommonAttributesHelper helper; + @Autowired private ForumService forumService; + + // ── Inbox ──────────────────────────────────────────────────────────────── + + @GetMapping("/messages/inbox") + public String inbox(Model model, Authentication auth) { + helper.addCommonAttributes(model, auth); + User user = helper.getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + + List messages = + messageRepository.findByRecipientAndDeletedByRecipientFalseOrderBySentAtDesc(user); + model.addAttribute("messages", messages); + return "messages/inbox"; + } + + // ── Outbox ─────────────────────────────────────────────────────────────── + + @GetMapping("/messages/outbox") + public String outbox(Model model, Authentication auth) { + helper.addCommonAttributes(model, auth); + User user = helper.getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + + List messages = + messageRepository.findBySenderAndDeletedBySenderFalseOrderBySentAtDesc(user); + model.addAttribute("messages", messages); + return "messages/outbox"; + } + + // ── Compose — show form ────────────────────────────────────────────────── + + @GetMapping("/messages/compose") + public String composeForm( + @RequestParam(required = false, defaultValue = "") String to, + @RequestParam(required = false, defaultValue = "") String subject, + Model model, Authentication auth) { + + helper.addCommonAttributes(model, auth); + if (helper.getCurrentUser(auth) == null) return "redirect:/loginform"; + + // Truncate pre-filled subject to 200 chars to stay within the field limit + if (subject.length() > 200) subject = subject.substring(0, 200); + + model.addAttribute("toUsername", to); + model.addAttribute("subjectPrefill", subject); + model.addAttribute("errors", new ArrayList<>()); + return "messages/compose"; + } + + // ── Compose — send message ─────────────────────────────────────────────── + + @PostMapping("/messages/compose") + public String sendMessage( + @RequestParam String to, + @RequestParam String subject, + @RequestParam String content, + Model model, Authentication auth) { + + helper.addCommonAttributes(model, auth); + User sender = helper.getCurrentUser(auth); + if (sender == null) return "redirect:/loginform"; + + List errors = new ArrayList<>(); + + // Validate subject and content + if (subject == null || subject.isBlank() || subject.length() > 200) + errors.add("Subject must be between 1 and 200 characters."); + if (content == null || content.isBlank() || content.length() > 4999) + errors.add("Message must be between 1 and 4999 characters."); + + // Look up the recipient by username + Optional recipientOpt = userRepository.findByUsername(to); + if (recipientOpt.isEmpty()) + errors.add("No user with the username \"" + to + "\" exists."); + + // If any errors, re-render the compose form with the values intact + if (!errors.isEmpty()) { + model.addAttribute("toUsername", to); + model.addAttribute("subjectPrefill", subject); + model.addAttribute("errors", errors); + return "messages/compose"; + } + + Message message = new Message(sender, recipientOpt.get(), subject, content); + messageRepository.save(message); + return "redirect:/messages/outbox"; + } + + // ── View a message ─────────────────────────────────────────────────────── + + @GetMapping("/messages/view") + public String viewMessage( + @RequestParam long id, + Model model, Authentication auth) { + + helper.addCommonAttributes(model, auth); + User user = helper.getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + + Optional opt = messageRepository.findById(id); + if (opt.isEmpty()) return "redirect:/messages/inbox"; + Message message = opt.get(); + + // Security check — only the sender or recipient may view this message + boolean isSender = message.getSender().getId().equals(user.getId()); + boolean isRecipient = message.getRecipient().getId().equals(user.getId()); + if (!isSender && !isRecipient) return "redirect:/"; + + // Mark as read when the recipient opens it for the first time + if (isRecipient && !message.isRead()) { + message.setRead(true); + messageRepository.save(message); + } + + // Build the reply URL with a properly encoded subject line + String encodedSubject = URLEncoder.encode("Re: " + message.getSubject(), StandardCharsets.UTF_8); + String replyUrl = "/messages/compose?to=" + message.getSender().getUsername() + + "&subject=" + encodedSubject; + + model.addAttribute("message", message); + model.addAttribute("renderedBody", forumService.renderMarkdown(message.getContent())); + model.addAttribute("replyUrl", replyUrl); + model.addAttribute("isSender", isSender); + return "messages/view"; + } + + // ── Delete (soft) ──────────────────────────────────────────────────────── + + @PostMapping("/messages/delete") + public String deleteMessage( + @RequestParam long id, + @RequestParam String box, + Authentication auth) { + + User user = helper.getCurrentUser(auth); + if (user == null) return "redirect:/loginform"; + + Optional opt = messageRepository.findById(id); + if (opt.isEmpty()) return "redirect:/messages/inbox"; + Message message = opt.get(); + + if ("outbox".equals(box) && message.getSender().getId().equals(user.getId())) { + message.setDeletedBySender(true); + messageRepository.save(message); + return "redirect:/messages/outbox"; + } + + if ("inbox".equals(box) && message.getRecipient().getId().equals(user.getId())) { + message.setDeletedByRecipient(true); + messageRepository.save(message); + } + + return "redirect:/messages/inbox"; + } +} diff --git a/java/src/main/java/com/zipcode/stardust/controller/UploadController.java b/java/src/main/java/com/zipcode/stardust/controller/UploadController.java new file mode 100644 index 0000000..f3c4333 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/controller/UploadController.java @@ -0,0 +1,95 @@ +package com.zipcode.stardust.controller; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Map; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +public class UploadController { + + @Value("${upload.dir:uploads}") + private String uploadDir; + + /** Accept any image/* or video/* type except SVG (can carry embedded scripts). */ + private boolean isAllowedType(String contentType) { + if (contentType == null) return false; + if ("image/svg+xml".equals(contentType)) return false; + return contentType.startsWith("image/") || contentType.startsWith("video/"); + } + + @PostMapping("/upload") + public ResponseEntity upload(@RequestParam MultipartFile file, + Authentication auth) { + if (auth == null || !auth.isAuthenticated() + || "anonymousUser".equals(auth.getPrincipal())) { + return ResponseEntity.status(401).body(Map.of("error", "Login required.")); + } + + if (file.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "File is empty.")); + } + + String rawType = file.getContentType(); + if (!isAllowedType(rawType)) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Unsupported file type: " + rawType)); + } + // isAllowedType returned true, so rawType is non-null here + String contentType = rawType; + + String ext = getExtension(file.getOriginalFilename(), contentType); + String filename = UUID.randomUUID() + ext; + + try { + Path dir = Paths.get(uploadDir); + Files.createDirectories(dir); + try (InputStream in = file.getInputStream()) { + Files.copy(in, dir.resolve(filename), StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + return ResponseEntity.internalServerError() + .body(Map.of("error", "Could not save file.")); + } + + return ResponseEntity.ok(Map.of( + "url", "/uploads/" + filename, + "type", contentType.startsWith("video/") ? "video" : "image" + )); + } + + private String getExtension(String originalFilename, String contentType) { + if (originalFilename != null && originalFilename.contains(".")) { + return originalFilename.substring(originalFilename.lastIndexOf('.')).toLowerCase(); + } + // Fallback from MIME type + return switch (contentType) { + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "image/gif" -> ".gif"; + case "image/webp" -> ".webp"; + case "image/bmp" -> ".bmp"; + case "image/svg+xml" -> ".svg"; + case "image/avif" -> ".avif"; + case "video/mp4" -> ".mp4"; + case "video/webm" -> ".webm"; + case "video/ogg" -> ".ogv"; + case "video/quicktime" -> ".mov"; + case "video/x-msvideo" -> ".avi"; + case "video/x-matroska" -> ".mkv"; + default -> ".bin"; + }; + } +} diff --git a/java/src/main/java/com/zipcode/stardust/model/MediaEmbed.java b/java/src/main/java/com/zipcode/stardust/model/MediaEmbed.java new file mode 100644 index 0000000..1a3a725 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/model/MediaEmbed.java @@ -0,0 +1,132 @@ +package com.zipcode.stardust.model; + +import jakarta.persistence.*; + +/** + * ============================================================= + * MediaEmbed — a database entity that stores one media link + * attached to a Post. + * + * KEY CONCEPTS FOR LEARNERS + * -------------------------- + * @Entity → tells JPA "make a table for this class" + * @Table → lets us name the table ourselves ("media_embed") + * @Id → marks the primary-key field + * @GeneratedValue → the database auto-assigns the id number + * @ManyToOne → many embeds can belong to ONE post + * @JoinColumn → the foreign-key column in the DB table + * @Enumerated → stores our enum as a readable string ("IMAGE" + * or "VIDEO") instead of a number + * ============================================================= + */ +@Entity +@Table(name = "media_embed") +public class MediaEmbed { + + // ── Enum ──────────────────────────────────────────────── + /** + * MediaType limits the kind of media to IMAGE or VIDEO. + * Using an enum prevents typos like "img" vs "image". + */ + public enum MediaType { + IMAGE, + VIDEO + } + + // ── Fields ────────────────────────────────────────────── + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The URL the user pasted (e.g. https://i.imgur.com/abc.jpg). + * nullable = false → the DB will reject a row with no URL. + * length = 2048 → URLs can be long; 255 (the default) is too short. + */ + @Column(nullable = false, length = 2048) + private String url; + + /** + * IMAGE or VIDEO — stored as the enum name string in the DB. + * EnumType.STRING is safer than ORDINAL because adding new + * enum values won't silently corrupt old data. + */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MediaType mediaType; + + /** + * Optional caption the user may provide (e.g. "My cat"). + * nullable = true (default) means it is allowed to be blank. + */ + @Column(length = 300) + private String caption; + + /** + * The post this embed belongs to. + * FetchType.LAZY → don't load the whole Post object from the + * DB until we actually call getPost(); saves memory. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + // ── Constructors ───────────────────────────────────────── + + /** JPA needs a no-argument constructor (it uses reflection). */ + public MediaEmbed() {} + + /** + * Convenience constructor used in the controller when saving + * a new embed. + * + * @param url the media URL + * @param mediaType IMAGE or VIDEO + * @param caption optional caption (may be null or blank) + * @param post the parent post + */ + public MediaEmbed(String url, MediaType mediaType, String caption, Post post) { + this.url = url; + this.mediaType = mediaType; + this.caption = caption; + this.post = post; + } + + // ── Getters & Setters ──────────────────────────────────── + // These let other classes read/write the private fields. + // Spring and Thymeleaf use getters heavily. + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + + public MediaType getMediaType() { return mediaType; } + public void setMediaType(MediaType mediaType) { this.mediaType = mediaType; } + + public String getCaption() { return caption; } + public void setCaption(String caption){ this.caption = caption; } + + public Post getPost() { return post; } + public void setPost(Post post) { this.post = post; } + + // ── Helper used in Thymeleaf templates ─────────────────── + + /** + * Returns true when this embed is an IMAGE. + * Thymeleaf can call th:if="${embed.image}" instead of + * comparing the enum by name — cleaner templates! + */ + public boolean isImage() { + return MediaType.IMAGE == this.mediaType; + } + + /** + * Returns true when this embed is a VIDEO. + */ + public boolean isVideo() { + return MediaType.VIDEO == this.mediaType; + } +} \ No newline at end of file diff --git a/java/src/main/java/com/zipcode/stardust/model/Message.java b/java/src/main/java/com/zipcode/stardust/model/Message.java new file mode 100644 index 0000000..6bc59e1 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/model/Message.java @@ -0,0 +1,91 @@ +package com.zipcode.stardust.model; + +import java.time.Duration; +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "message") +public class Message { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipient_id") + private User recipient; + + @Column(nullable = false, length = 200) + private String subject; + + @Column(nullable = false, length = 5000) + private String content; + + @Column(nullable = false) + private LocalDateTime sentAt; + + @Column(name = "is_read", nullable = false) + private boolean read = false; + + @Column(nullable = false) + private boolean deletedBySender = false; + + @Column(nullable = false) + private boolean deletedByRecipient = false; + + public Message() {} + + public Message(User sender, User recipient, String subject, String content) { + this.sender = sender; + this.recipient = recipient; + this.subject = subject; + this.content = content; + this.sentAt = LocalDateTime.now(); + } + + public String getTimeString() { + Duration d = Duration.between(sentAt, LocalDateTime.now()); + long months = d.toDays() / 30; + long days = d.toDays(); + long hours = d.toHours(); + long minutes = d.toMinutes(); + if (months > 0) return months + " month" + (months == 1 ? "" : "s") + " ago"; + if (days > 0) return days + " day" + (days == 1 ? "" : "s") + " ago"; + if (hours > 0) return hours + " hour" + (hours == 1 ? "" : "s") + " ago"; + if (minutes > 0) return minutes + " minute" + (minutes == 1 ? "" : "s") + " ago"; + return "Just now"; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public User getSender() { return sender; } + public void setSender(User sender) { this.sender = sender; } + public User getRecipient() { return recipient; } + public void setRecipient(User recipient) { this.recipient = recipient; } + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public LocalDateTime getSentAt() { return sentAt; } + public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; } + public boolean isRead() { return read; } + public void setRead(boolean read) { this.read = read; } + public boolean isDeletedBySender() { return deletedBySender; } + public void setDeletedBySender(boolean v) { this.deletedBySender = v; } + public boolean isDeletedByRecipient() { return deletedByRecipient; } + public void setDeletedByRecipient(boolean v) { this.deletedByRecipient = v; } +} diff --git a/java/src/main/java/com/zipcode/stardust/model/Reaction.java b/java/src/main/java/com/zipcode/stardust/model/Reaction.java new file mode 100644 index 0000000..0302759 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/model/Reaction.java @@ -0,0 +1,42 @@ +package com.zipcode.stardust.model; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +@Entity +public class Reaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String type; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; //user relationship + + @ManyToOne + @JoinColumn(name = "post_id") + private Post post; //post relationship + + @ManyToOne + @JoinColumn(name = "comment_id" , nullable = true) + private Comment comment; //comment relationship + + //getters and setters +public Long getId() { return id; } + +public String getType() { return type; } +public void setType(String type) { this.type = type; } + +public User getUser() { return user; } +public void setUser(User user) { this.user = user; } + +public Post getPost() { return post; } +public void setPost(Post post) { this.post = post; } + +public Comment getComment() { return comment; } +public void setComment(Comment comment) { this.comment = comment; } +} diff --git a/java/src/main/java/com/zipcode/stardust/model/User.java b/java/src/main/java/com/zipcode/stardust/model/User.java index 7b02b2d..0542a3a 100644 --- a/java/src/main/java/com/zipcode/stardust/model/User.java +++ b/java/src/main/java/com/zipcode/stardust/model/User.java @@ -19,7 +19,7 @@ import jakarta.persistence.Table; @Entity -@Table(name = "\"user\"") +@Table(name = "users") public class User implements UserDetails { @Id @@ -38,6 +38,12 @@ public class User implements UserDetails { @Column(nullable = false) private boolean admin = false; + @Column(nullable = false, columnDefinition = "varchar(255) not null default 'ROLE_USER'") + private String role = "ROLE_USER"; // ADDED role field + + @Column(nullable = false, columnDefinition = "boolean not null default false") + private boolean banned = false; // ADDED banned field + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List posts = new ArrayList<>(); @@ -46,11 +52,19 @@ public class User implements UserDetails { public User() {} + private static final java.util.Set ADMIN_USERNAMES = java.util.Set.of( + "admin", "ZoeDayz", "jumz", "Joseph", "nishat" + ); + + public static boolean isPrivilegedUsername(String username) { + return username != null && ADMIN_USERNAMES.contains(username); + } + public User(String email, String username, String rawPassword, PasswordEncoder encoder) { this.email = email; this.username = username; this.passwordHash = encoder.encode(rawPassword); - this.admin = "admin".equalsIgnoreCase(username); + this.admin = isPrivilegedUsername(username); } public boolean checkPassword(String rawPassword, PasswordEncoder encoder) { @@ -60,7 +74,7 @@ public boolean checkPassword(String rawPassword, PasswordEncoder encoder) { @Override public Collection getAuthorities() { List authorities = new ArrayList<>(); - authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + authorities.add(new SimpleGrantedAuthority(role)); // ADD - uses role field if (admin) { authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); } @@ -81,7 +95,7 @@ public String getUsername() { public boolean isAccountNonExpired() { return true; } @Override - public boolean isAccountNonLocked() { return true; } + public boolean isAccountNonLocked() { return !banned; } // ADD - uses banned field @Override public boolean isCredentialsNonExpired() { return true; } @@ -98,8 +112,17 @@ public String getUsername() { public void setEmail(String email) { this.email = email; } public boolean isAdmin() { return admin; } public void setAdmin(boolean admin) { this.admin = admin; } + + // ADD - role getters/setters + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + + // ADD - banned getters/setters + public boolean isBanned() { return banned; } + public void setBanned(boolean banned) { this.banned = banned; } + public List getPosts() { return posts; } public void setPosts(List posts) { this.posts = posts; } public List getComments() { return comments; } public void setComments(List comments) { this.comments = comments; } -} +} \ No newline at end of file diff --git a/java/src/main/java/com/zipcode/stardust/model/UserProfile.java b/java/src/main/java/com/zipcode/stardust/model/UserProfile.java new file mode 100644 index 0000000..4a8362b --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/model/UserProfile.java @@ -0,0 +1,72 @@ +package com.zipcode.stardust.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_profiles") +public class UserProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String bio; + + private String email; + + private LocalDateTime joinDate; + + @OneToOne + @JoinColumn(name = "user_id") + private User user; + + public UserProfile() {} + + public UserProfile(User user) { + this.user = user; + this.email = user.getEmail(); + this.joinDate = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getBio() { + return bio; + } + + public void setBio(String bio) { + this.bio = bio; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public LocalDateTime getJoinDate() { + return joinDate; + } + + public void setJoinDate(LocalDateTime joinDate) { + this.joinDate = joinDate; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + +} \ No newline at end of file diff --git a/java/src/main/java/com/zipcode/stardust/repository/MediaEmbedRepository.java b/java/src/main/java/com/zipcode/stardust/repository/MediaEmbedRepository.java new file mode 100644 index 0000000..920f16f --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/repository/MediaEmbedRepository.java @@ -0,0 +1,10 @@ +package com.zipcode.stardust.repository; + +import com.zipcode.stardust.model.MediaEmbed; +import com.zipcode.stardust.model.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface MediaEmbedRepository extends JpaRepository { + List findByPostOrderByIdAsc(Post post); +} diff --git a/java/src/main/java/com/zipcode/stardust/repository/MessageRepository.java b/java/src/main/java/com/zipcode/stardust/repository/MessageRepository.java new file mode 100644 index 0000000..29ae5ae --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/repository/MessageRepository.java @@ -0,0 +1,16 @@ +package com.zipcode.stardust.repository; + +import com.zipcode.stardust.model.Message; +import com.zipcode.stardust.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MessageRepository extends JpaRepository { + + List findByRecipientAndDeletedByRecipientFalseOrderBySentAtDesc(User recipient); + + List findBySenderAndDeletedBySenderFalseOrderBySentAtDesc(User sender); + + long countByRecipientAndReadFalseAndDeletedByRecipientFalse(User recipient); +} diff --git a/java/src/main/java/com/zipcode/stardust/repository/ReactionRepository.java b/java/src/main/java/com/zipcode/stardust/repository/ReactionRepository.java new file mode 100644 index 0000000..83ef4bd --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/repository/ReactionRepository.java @@ -0,0 +1,27 @@ +package com.zipcode.stardust.repository; + + + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.zipcode.stardust.model.Comment; +import com.zipcode.stardust.model.Post; +import com.zipcode.stardust.model.Reaction; +import com.zipcode.stardust.model.User; + +@Repository +public interface ReactionRepository extends JpaRepository { + + // check if a user already reacted to a post + Reaction findByUserAndPost(User user, Post post); + + // check if a user already reacted to a comment + Reaction findByUserAndComment(User user, Comment comment); + + // count likes or dislikes on a post + Long countByPostAndType(Post post, String type); + + // count likes or dislikes on a comment + Long countByCommentAndType(Comment comment, String type); +} diff --git a/java/src/main/java/com/zipcode/stardust/repository/UserProfileRepository.java b/java/src/main/java/com/zipcode/stardust/repository/UserProfileRepository.java new file mode 100644 index 0000000..59e14c4 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/repository/UserProfileRepository.java @@ -0,0 +1,12 @@ +package com.zipcode.stardust.repository; +import com.zipcode.stardust.model.UserProfile; + +import com.zipcode.stardust.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserProfileRepository extends JpaRepository { + + + + UserProfile findByUser(User user); +} \ No newline at end of file diff --git a/java/src/main/java/com/zipcode/stardust/repository/UserRepository.java b/java/src/main/java/com/zipcode/stardust/repository/UserRepository.java index 9a98b06..48be50f 100644 --- a/java/src/main/java/com/zipcode/stardust/repository/UserRepository.java +++ b/java/src/main/java/com/zipcode/stardust/repository/UserRepository.java @@ -1,9 +1,11 @@ package com.zipcode.stardust.repository; -import com.zipcode.stardust.model.User; -import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.zipcode.stardust.model.User; + public interface UserRepository extends JpaRepository { Optional findByUsername(String username); Optional findByEmail(String email); diff --git a/java/src/main/java/com/zipcode/stardust/service/CommonAttributesHelper.java b/java/src/main/java/com/zipcode/stardust/service/CommonAttributesHelper.java new file mode 100644 index 0000000..f5d07cc --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/service/CommonAttributesHelper.java @@ -0,0 +1,48 @@ +package com.zipcode.stardust.service; + +import com.zipcode.stardust.model.User; +import com.zipcode.stardust.repository.MessageRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.ui.Model; + +@Component +public class CommonAttributesHelper { + + @Autowired + private MessageRepository messageRepository; + + @Value("${site.name:Schooner}") + private String siteName; + + @Value("${site.description:a schooner forum}") + private String siteDescription; + + public User getCurrentUser(Authentication auth) { + if (auth == null || !auth.isAuthenticated() || + "anonymousUser".equals(auth.getPrincipal())) { + return null; + } + return (User) auth.getPrincipal(); + } + + public void addCommonAttributes(Model model, Authentication auth) { + model.addAttribute("siteName", siteName); + model.addAttribute("siteDescription", siteDescription); + + User user = getCurrentUser(auth); + if (user != null) { + model.addAttribute("isLoggedIn", true); + model.addAttribute("currentUser", user.getUsername()); + model.addAttribute("isAdmin", user.isAdmin()); + model.addAttribute("unreadCount", + messageRepository.countByRecipientAndReadFalseAndDeletedByRecipientFalse(user)); + } else { + model.addAttribute("isLoggedIn", false); + model.addAttribute("isAdmin", false); + model.addAttribute("unreadCount", 0L); + } + } +} diff --git a/java/src/main/java/com/zipcode/stardust/service/ForumService.java b/java/src/main/java/com/zipcode/stardust/service/ForumService.java index 1ebbc72..cbbfe29 100644 --- a/java/src/main/java/com/zipcode/stardust/service/ForumService.java +++ b/java/src/main/java/com/zipcode/stardust/service/ForumService.java @@ -1,13 +1,32 @@ package com.zipcode.stardust.service; -import com.zipcode.stardust.model.Subforum; -import com.zipcode.stardust.repository.SubforumRepository; -import com.zipcode.stardust.repository.UserRepository; +import java.util.Optional; + +import org.commonmark.ext.autolink.AutolinkExtension; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.ext.task.list.items.TaskListItemsExtension; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.owasp.html.HtmlPolicyBuilder; +import org.owasp.html.PolicyFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.util.HtmlUtils; -import java.util.Optional; +import com.zipcode.stardust.model.Comment; +import com.zipcode.stardust.model.Post; +import com.zipcode.stardust.model.Reaction; +import com.zipcode.stardust.model.Subforum; +import com.zipcode.stardust.model.User; +import com.zipcode.stardust.model.UserProfile; +import com.zipcode.stardust.repository.CommentRepository; +import com.zipcode.stardust.repository.PostRepository; +import com.zipcode.stardust.repository.ReactionRepository; +import com.zipcode.stardust.repository.SubforumRepository; +import com.zipcode.stardust.repository.UserProfileRepository; +import com.zipcode.stardust.repository.UserRepository; @Service public class ForumService { @@ -15,10 +34,62 @@ public class ForumService { @Autowired private SubforumRepository subforumRepository; + @Autowired + private PostRepository postRepository; + + @Autowired + private CommentRepository commentRepository; + @Autowired private UserRepository userRepository; - public String generateLinkPath(Long subforumId) { + @Autowired + private UserProfileRepository userProfileRepository; + + @Autowired + private ReactionRepository reactionRepository; + + // Markdown rendering pipeline — thread-safe singletons + private static final java.util.List MD_EXTENSIONS = java.util.List.of( + TablesExtension.create(), + StrikethroughExtension.create(), + TaskListItemsExtension.create(), + AutolinkExtension.create() + ); + private final Parser mdParser = Parser.builder().extensions(MD_EXTENSIONS).build(); + private final HtmlRenderer mdRenderer = HtmlRenderer.builder().extensions(MD_EXTENSIONS).build(); + private final PolicyFactory sanitizer = new HtmlPolicyBuilder() + .allowElements("p", "br", "hr", "b", "strong", "em", "i", "u", "s", + "code", "pre", "blockquote", "ul", "ol", "li", + "h1", "h2", "h3", "h4") + .allowUrlProtocols("http", "https") + .allowElements("a") + .allowAttributes("href").onElements("a") + .requireRelNofollowOnLinks() + .allowElements("img") + .allowAttributes("src", "alt").onElements("img") + // Tables (GFM) + .allowElements("table", "thead", "tbody", "tr", "th", "td") + .allowAttributes("align").onElements("th", "td") + // Task list checkboxes + .allowElements("input") + .allowAttributes("type", "disabled", "checked").onElements("input") + // Inline spans for font size and font family + .allowElements("span") + .allowStyling() + // Uploaded video embeds + .allowElements("video") + .allowAttributes("src", "controls", "width", "height").onElements("video") + .toFactory(); + + public String renderMarkdown(String raw) { + if (raw == null) return ""; + Node document = mdParser.parse(raw); + String html = mdRenderer.render(document); + return sanitizer.sanitize(html); + } + + public String generateLinkPath(long subforumId) { StringBuilder sb = new StringBuilder(); sb.append(" / Forum Index"); Optional opt = subforumRepository.findById(subforumId); @@ -63,4 +134,128 @@ public boolean usernameTaken(String username) { public boolean emailTaken(String email) { return userRepository.existsByEmail(email); } + + + // ADD 2 - UserProfile methods + public UserProfile getUserProfile(User user) { + return userProfileRepository.findByUser(user); + } + + public UserProfile createUserProfile(User user) { + UserProfile profile = new UserProfile(user); + return userProfileRepository.save(profile); + } + + public UserProfile updateBio(User user, String bio) { + UserProfile profile = userProfileRepository.findByUser(user); + if (profile == null) profile = new UserProfile(user); + profile.setBio(bio); + return userProfileRepository.save(profile); + } + + public boolean updateEmail(User user, String newEmail) { + if (newEmail == null || newEmail.isBlank()) return false; + if (emailTaken(newEmail) && !newEmail.equalsIgnoreCase(user.getEmail())) return false; + user.setEmail(newEmail); + userRepository.save(user); + return true; + } + + public boolean updatePassword(User user, String currentRaw, String newRaw, + org.springframework.security.crypto.password.PasswordEncoder encoder) { + if (!user.checkPassword(currentRaw, encoder)) return false; + if (!validPassword(newRaw)) return false; + user.setPasswordHash(encoder.encode(newRaw)); + userRepository.save(user); + return true; + } + + + + // ── REACTIONS ──────────────────────────────────────────────── + + public void reactToPost(User user, Post post, String type) { + Reaction existing = reactionRepository.findByUserAndPost(user, post); + + if (existing == null) { + Reaction reaction = new Reaction(); + reaction.setUser(user); + reaction.setPost(post); + reaction.setType(type); + reactionRepository.save(reaction); + + } else if (existing.getType().equals(type)) { + reactionRepository.delete(existing); + + } else { + existing.setType(type); + reactionRepository.save(existing); + } + } + + public Long getLikeCount(Post post) { + return reactionRepository.countByPostAndType(post, "LIKE"); + } + + public Long getDislikeCount(Post post) { + return reactionRepository.countByPostAndType(post, "DISLIKE"); + } + + public Long getFireCount(Post post) { + return reactionRepository.countByPostAndType(post, "FIRE"); + } + + public Long getFunnyCount(Post post) { + return reactionRepository.countByPostAndType(post, "FUNNY"); + } + + public Long getSadCount(Post post) { + return reactionRepository.countByPostAndType(post, "SAD"); + } + + public Long getCelebrateCount(Post post) { + return reactionRepository.countByPostAndType(post, "CELEBRATE"); + } + + public Reaction getUserReaction(User user, Post post) { + return reactionRepository.findByUserAndPost(user, post); + } + + public void moderatePost(long postId) { + postRepository.deleteById(postId); + } + + public void moderateComment(long commentId) { + commentRepository.deleteById(commentId); + } + + public boolean editPost(long postId, String title, String content, User requestingUser) { + Optional opt = postRepository.findById(postId); + if (opt.isEmpty()) return false; + Post post = opt.get(); + if (!post.getUser().getId().equals(requestingUser.getId())) return false; + post.setTitle(title); + post.setContent(content); + postRepository.save(post); + return true; + } + + public boolean editComment(long commentId, String content, User requestingUser) { + Optional opt = commentRepository.findById(commentId); + if (opt.isEmpty()) return false; + Comment comment = opt.get(); + if (!comment.getUser().getId().equals(requestingUser.getId())) return false; + comment.setContent(content); + commentRepository.save(comment); + return true; + } + + public void banUser(String username) { + Optional opt = userRepository.findByUsername(username); + if (opt.isPresent()) { + User user = opt.get(); + user.setBanned(true); + userRepository.save(user); + } + } } diff --git a/java/src/main/java/com/zipcode/stardust/service/MediaEmbedService.java b/java/src/main/java/com/zipcode/stardust/service/MediaEmbedService.java new file mode 100644 index 0000000..e4e11a0 --- /dev/null +++ b/java/src/main/java/com/zipcode/stardust/service/MediaEmbedService.java @@ -0,0 +1,157 @@ +package com.zipcode.stardust.service; + +import com.zipcode.stardust.model.MediaEmbed; +import com.zipcode.stardust.model.MediaEmbed.MediaType; +import com.zipcode.stardust.model.Post; +import com.zipcode.stardust.repository.MediaEmbedRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import org.springframework.lang.NonNull; + +/** + * ============================================================= + * MediaEmbedService — business logic for media embedding. + * + * KEY CONCEPTS FOR LEARNERS + * -------------------------- + * @Service → marks this as a Spring-managed "service" bean. + * Spring creates one instance and shares it across + * the whole app (singleton by default). + * + * @Autowired → Spring automatically injects (provides) the + * MediaEmbedRepository; you don't call "new". + * + * Why a Service layer? + * Controllers should only handle HTTP (requests/responses). + * Repositories should only talk to the database. + * Services sit in the middle and hold the business rules — + * validation, detection logic, saving — keeping each layer + * focused on one job (the "Single Responsibility Principle"). + * ============================================================= + */ +@Service +public class MediaEmbedService { + + @Autowired + private MediaEmbedRepository mediaEmbedRepository; + + // ── URL validation ──────────────────────────────────────── + + /** + * Checks that a URL is non-blank and starts with http:// or + * https://. We reject everything else to block javascript: + * and other dangerous schemes. + * + * @param url the URL string to check + * @return true if the URL looks safe and well-formed + */ + public boolean isValidUrl(String url) { + if (url == null || url.isBlank()) { + return false; + } + String lower = url.strip().toLowerCase(); + // Only allow http and https — block javascript:, data:, etc. + return lower.startsWith("http://") || lower.startsWith("https://"); + } + + // ── Media-type detection ────────────────────────────────── + + /** + * Detects whether a URL points to an IMAGE or a VIDEO. + * + * How it works: + * 1. Strip query parameters (?foo=bar) so ".jpg?size=large" + * is still recognised as an image. + * 2. Check the file extension against known image extensions. + * 3. Check for known video-hosting domains. + * 4. Default to IMAGE if we can't tell (handles imgur, etc.). + * + * @param url the URL to inspect + * @return IMAGE or VIDEO + */ + public MediaType detectMediaType(String url) { + if (url == null) return MediaType.IMAGE; + + // Remove query string for extension matching + String path = url.split("\\?")[0].toLowerCase(); + + // Common image file extensions + if (path.endsWith(".jpg") || path.endsWith(".jpeg") || + path.endsWith(".png") || path.endsWith(".gif") || + path.endsWith(".webp") || path.endsWith(".svg") || + path.endsWith(".bmp")) { + return MediaType.IMAGE; + } + + // Common video file extensions + if (path.endsWith(".mp4") || path.endsWith(".webm") || + path.endsWith(".ogg") || path.endsWith(".mov")) { + return MediaType.VIDEO; + } + + // Known video hosting domains + String lower = url.toLowerCase(); + if (lower.contains("youtube.com") || lower.contains("youtu.be") || + lower.contains("vimeo.com") || lower.contains("twitch.tv")) { + return MediaType.VIDEO; + } + + // When in doubt, treat it as an image + return MediaType.IMAGE; + } + + // ── Caption validation ──────────────────────────────────── + + /** + * Captions are optional but must not exceed 300 characters + * if provided. Returns true when the caption is acceptable. + * + * @param caption the caption string (may be null or blank) + * @return true if valid + */ + public boolean isValidCaption(String caption) { + if (caption == null || caption.isBlank()) { + return true; // captions are optional + } + return caption.length() <= 300; + } + + // ── CRUD operations ─────────────────────────────────────── + + /** + * Saves one MediaEmbed to the database. + * The repository's save() method handles both INSERT (new + * record) and UPDATE (existing record with same id). + * + * @param embed the embed to persist + * @return the saved embed (id is now populated) + */ + @NonNull + public MediaEmbed save(@NonNull MediaEmbed embed) { + return mediaEmbedRepository.save(embed); + } + + /** + * Returns all embeds attached to a post, in insertion order. + * + * @param post the parent post + * @return list of MediaEmbed objects (may be empty) + */ + public List getEmbedsForPost(Post post) { + return mediaEmbedRepository.findByPostOrderByIdAsc(post); + } + + /** + * Deletes a single embed by its id. + * deleteById() does nothing if the id doesn't exist, + * so this is safe to call even with stale ids. + * + * @param embedId the id of the embed to remove + */ + public void deleteEmbed(long embedId) { + mediaEmbedRepository.deleteById(embedId); + } +} + diff --git a/java/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/java/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..cd35056 --- /dev/null +++ b/java/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,21 @@ +{ + "properties": [ + { + "name": "site.name", + "type": "java.lang.String", + "description": "A description for 'site.name'" + }, + { + "name": "site.description", + "type": "java.lang.String", + "description": "A description for 'site.description'" + }, + { + "name": "upload.dir", + "type": "java.lang.String", + "description": "Directory where user-uploaded media files are stored.", + "defaultValue": "uploads" + } + ] +} + diff --git a/java/src/main/resources/application.properties b/java/src/main/resources/application.properties index 3a5f22b..6a68744 100644 --- a/java/src/main/resources/application.properties +++ b/java/src/main/resources/application.properties @@ -1,10 +1,14 @@ -spring.datasource.url=jdbc:sqlite:Stardust.db -spring.datasource.driver-class-name=org.sqlite.JDBC -spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect +spring.datasource.url=jdbc:postgresql://xo.zipcode.rocks:9088/circus +spring.datasource.username=sunflower_user +spring.datasource.password=zipmusic +spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false -server.port=5000 +server.port=8080 spring.application.name=Schooner site.name=Schooner site.description=a schooner forum spring.thymeleaf.cache=false +upload.dir=uploads +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB diff --git a/java/src/main/resources/static/style.css b/java/src/main/resources/static/style.css index d69c81d..e0b1847 100644 --- a/java/src/main/resources/static/style.css +++ b/java/src/main/resources/static/style.css @@ -19,3 +19,6 @@ body { padding: 10px; margin-bottom: 10px; } +.message-list .list-group-item { + border-left: 4px solid #6f42c1; +} diff --git a/java/src/main/resources/templates/createpost.html b/java/src/main/resources/templates/createpost.html index ee04f65..7742468 100644 --- a/java/src/main/resources/templates/createpost.html +++ b/java/src/main/resources/templates/createpost.html @@ -1,7 +1,7 @@ - +
@@ -19,12 +19,120 @@

Create Post in Forum

-
Cancel + + + - + \ No newline at end of file diff --git a/java/src/main/resources/templates/editpost.html b/java/src/main/resources/templates/editpost.html new file mode 100644 index 0000000..7f0cc18 --- /dev/null +++ b/java/src/main/resources/templates/editpost.html @@ -0,0 +1,129 @@ + + + + +
+
+

Edit Post

+
+ +
+ + +
+
+ + + +
+
+ + + +
+ + + + +
+ +
+
+ + Cancel +
+
+
+ + + + diff --git a/java/src/main/resources/templates/header.html b/java/src/main/resources/templates/header.html index 8abe4a2..65816c4 100644 --- a/java/src/main/resources/templates/header.html +++ b/java/src/main/resources/templates/header.html @@ -7,15 +7,41 @@ Schooner diff --git a/java/src/main/resources/templates/layout.html b/java/src/main/resources/templates/layout.html index 0ad36f8..dd35b98 100644 --- a/java/src/main/resources/templates/layout.html +++ b/java/src/main/resources/templates/layout.html @@ -4,10 +4,18 @@ + + Page - Schooner + + + + +
diff --git a/java/src/main/resources/templates/messages/compose.html b/java/src/main/resources/templates/messages/compose.html new file mode 100644 index 0000000..ee3f9bc --- /dev/null +++ b/java/src/main/resources/templates/messages/compose.html @@ -0,0 +1,140 @@ + + + + +
+
+ +

Compose Message

+ +
+
    +
  • Error
  • +
+
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+ + + + +
+ +
+ + Cancel +
+ +
+ + + diff --git a/java/src/main/resources/templates/messages/inbox.html b/java/src/main/resources/templates/messages/inbox.html new file mode 100644 index 0000000..41da984 --- /dev/null +++ b/java/src/main/resources/templates/messages/inbox.html @@ -0,0 +1,41 @@ + + + + +
+
+ +
+

Inbox

+ Compose +
+ + + +
+ No messages. +
+ + + +
+ + diff --git a/java/src/main/resources/templates/messages/outbox.html b/java/src/main/resources/templates/messages/outbox.html new file mode 100644 index 0000000..cc5ef9c --- /dev/null +++ b/java/src/main/resources/templates/messages/outbox.html @@ -0,0 +1,36 @@ + + + + +
+
+ +
+

Sent Messages

+ Compose +
+ +
+ Inbox +
+ +
+ No sent messages. +
+ + + +
+ + diff --git a/java/src/main/resources/templates/messages/view.html b/java/src/main/resources/templates/messages/view.html new file mode 100644 index 0000000..0e6288a --- /dev/null +++ b/java/src/main/resources/templates/messages/view.html @@ -0,0 +1,40 @@ + + + + +
+
+ +
+ ← Back +
+ +
+
+
Subject
+ + From sender + to recipient + — time + +
+
Message content
+
+ +
+ Reply + +
+ + + +
+
+ +
+ + diff --git a/java/src/main/resources/templates/settings.html b/java/src/main/resources/templates/settings.html new file mode 100644 index 0000000..0a692b3 --- /dev/null +++ b/java/src/main/resources/templates/settings.html @@ -0,0 +1,64 @@ + + + + +
+
+

Account Settings

+ +
+
+
+
Settings saved.
+ + +
+
Bio
+
+
+
+ +
+ +
+
+
+ + +
+
Email Address
+
+
+
+ +
+ +
+
+
+ + +
+
Change Password
+
+
+
+ + +
+
+ + + 6-40 characters, alphanumeric + !@#%& +
+ +
+
+
+
+ + diff --git a/java/src/main/resources/templates/viewpost.html b/java/src/main/resources/templates/viewpost.html index 6762abc..9f11fdd 100644 --- a/java/src/main/resources/templates/viewpost.html +++ b/java/src/main/resources/templates/viewpost.html @@ -4,6 +4,18 @@
+ + +