Skip to content

Commit

Permalink
Merge pull request #63 from neumanf/feature/dashboard
Browse files Browse the repository at this point in the history
feat: add dashboard and management pages
  • Loading branch information
neumanf authored Sep 19, 2024
2 parents b1b30a6 + 147946e commit c3c45cd
Show file tree
Hide file tree
Showing 63 changed files with 9,214 additions and 7,941 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: CI

on:
push:
pull_request:

jobs:
format:
Expand Down
37 changes: 22 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
<!--suppress HtmlDeprecatedAttribute -->
<p align="center">
<a href="">
<img src="apps/ui/src/assets/logo.svg" height="220">
<img src="apps/ui/src/assets/logo-full.svg" height="220">
</a>
</p>

<h1 align="center">
Mally
</h1>
<h4 align="center">
A collection of open source web services
</h4>
[Mally](https://mally.neumanf.com) is a web application that provides a collection of web services, including a URL shortener and a Pastebin-like service for sharing text and code snippets. It uses Angular for the frontend and Spring Boot for a modular monolith in the backend, along with Keycloak managing authentication, RabbitMQ handling message queuing, and PostgreSQL for data storage. It also has automated testing with Cypress and a fully configured CI/CD pipeline with GitHub Actions.

## Features
## Key Features

- URL Shortener and Pastebin services
- CI/CD
- Tests
- URL Shortener with QR Code for easy copy and share
- Pastebin supporting over 10 programming languages
- Dashboard with statistics about your usage
- Management page for viewing, creating, updating and deleting data
- Authentication

## Stack
## Folder structure

- **Frontend:** Angular and PrimeNG.
- **Backend:** Springboot, RabbitMQ, PostgreSQL and Redis.
```lua
mally
|
├── apps -- Main folder where the application code is located
| ├── ui -- Angular front-end application
| └── api -- Spring Boot back-end application
├── infra -- Infraesctructure-related Docker files and configuration
| ├── keycloak -- Keycloak themes and configuration
| ├── nginx -- Nginx configuration files and scripts
| └── postgres -- PostgreSQL scripts
└── scripts -- Development scripts
```

## Architecture

Expand All @@ -42,9 +49,9 @@ flowchart LR
%% Flow
UI -->|HTTP|BACKEND
RMQ <-->|AMQP|BACKEND
subgraph BACKEND [API]
API <-->|AMQP|RMQ
direction LR
API --> POSTGRES
API --> REDIS
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.mally.api.auth;

import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

public class AuthenticationManager {

public static boolean isAuthenticated() {
var user = SecurityContextHolder.getContext().getAuthentication();
return user instanceof UserJwt;
}

public static Optional<UserJwt> getAuthenticatedUser() {
if (!isAuthenticated()) {
return Optional.empty();
}
return Optional.of((UserJwt) SecurityContextHolder.getContext().getAuthentication());
}

}
20 changes: 6 additions & 14 deletions apps/api/src/main/java/com/mally/api/auth/UserJwt.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.mally.api.auth;

import lombok.Getter;
import lombok.Setter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;

import java.util.Collection;


@Getter
@Setter
public class UserJwt extends JwtAuthenticationToken {

private String firstName;
Expand All @@ -17,19 +21,7 @@ public UserJwt(Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
super(jwt, authorities);
}

public String getFirstName() {
return firstName;
}

public void setFirstName(String firstName) {
this.firstName = firstName;
}

public String getLastName() {
return lastName;
}

public void setLastName(String lastName) {
this.lastName = lastName;
public String getId() {
return getName();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()))
.exceptionHandling(customizer -> customizer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests((requests) -> requests
.requestMatchers("/url-shortener/**").permitAll()
.requestMatchers( "/url-shortener/shorten", "/url-shortener/redirect/**").permitAll()
.requestMatchers("/pastebin/paste/**").permitAll()
.requestMatchers("/health/**").permitAll()
.requestMatchers("/auth/**").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package com.mally.api.pastebin;

import com.mally.api.auth.AuthenticationManager;
import com.mally.api.auth.UserJwt;
import com.mally.api.pastebin.dtos.CreatePasteDTO;
import com.mally.api.pastebin.entities.Paste;
import com.mally.api.pastebin.services.PastebinService;
import com.mally.api.shared.rest.dtos.ApiResponseDTO;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@AllArgsConstructor
@RestController
Expand All @@ -19,46 +24,56 @@ public class PastebinController {

private final PastebinService pastebinService;

@GetMapping("/")
public ResponseEntity<Page<Paste>> findAll(
@RequestParam(required = false) String search,
@RequestParam(defaultValue = "0") int pageNumber,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") String orderBy
) {
var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElseThrow();
var result = pastebinService.findAll(
userId,
search,
PageRequest.of(pageNumber, pageSize, Sort.by(orderBy.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy))
);

return ResponseEntity.ok(result);
}

@GetMapping("/paste/{slug}")
public ResponseEntity<ApiResponseDTO> findPaste(@PathVariable String slug) {
try {
Optional<Paste> paste = pastebinService.findBySlug(slug);
return paste
return pastebinService.findBySlug(slug)
.map(p -> ResponseEntity
.ok()
.body(ApiResponseDTO.success("Paste found successfully.", p)))
.orElseGet(() -> ResponseEntity
.badRequest()
.body(ApiResponseDTO.error("Paste not found.", List.of())));
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.body(
ApiResponseDTO.error(
"An unexpected error occurred while finding the paste.",
List.of()
)
);
}
}

@PostMapping("/paste")
public ResponseEntity<ApiResponseDTO> paste(@Valid @RequestBody CreatePasteDTO dto) {
try {
Paste paste = pastebinService.create(dto);
var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElse(null);
var paste = pastebinService.create(dto, userId);

return ResponseEntity
return ResponseEntity
.ok()
.body(ApiResponseDTO.success("Paste created successfully.", paste));
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.body(
ApiResponseDTO.error(
"An unexpected error occurred while creating the paste.",
List.of()
)
);
}
}

@DeleteMapping("/{id}")
public ResponseEntity<ApiResponseDTO> delete(@PathVariable Long id) {
pastebinService.delete(id);

return ResponseEntity.ok(ApiResponseDTO.success("Paste deleted successfully.", null));
}

@DeleteMapping("bulk")
public ResponseEntity<ApiResponseDTO> bulkDelete(@RequestParam List<Long> id) {
pastebinService.deleteMany(id);

return ResponseEntity.ok(ApiResponseDTO.success("Pastes deleted successfully.", null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.mally.api.pastebin.dtos;

import com.mally.api.pastebin.entities.PasteSyntax;
import com.mally.api.shared.rest.dtos.PaginationDTO;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class SearchPastesDTO extends PaginationDTO {
@Nullable
String slug;

@Nullable
String text;

@Nullable
PasteSyntax syntax;

@Nullable
Boolean encrypted;

@Nullable
String createdAt;

@Nullable
String expiresAt;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mally.api.pastebin.entities;

import jakarta.annotation.Nullable;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
Expand Down Expand Up @@ -38,6 +39,10 @@ public class Paste {
@NotNull
private boolean encrypted;

@Column()
@Nullable()
private String userId;

@Column(name = "created_at")
@CreatedDate
@NotNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.mally.api.pastebin.entities.Paste;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
Expand All @@ -12,11 +15,18 @@
import java.util.Optional;

@Repository
public interface PastebinRepository extends CrudRepository<Paste, Long> {
public interface PastebinRepository extends CrudRepository<Paste, Long>, JpaSpecificationExecutor<Paste> {

Page<Paste> findAll(Pageable pageable);

Page<Paste> findAllByUserId(String userId, Pageable pageable);

Optional<Paste> findBySlug(String slug);

@Transactional
@Modifying
@Query("DELETE FROM Paste p WHERE p.expiresAt < :now")
void deleteExpiredPastes(@Param("now") ZonedDateTime now);

Long countByUserId(String userId);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
package com.mally.api.pastebin.services;

import com.mally.api.pastebin.dtos.CreatePasteDTO;
import com.mally.api.pastebin.dtos.SearchPastesDTO;
import com.mally.api.pastebin.entities.Paste;
import com.mally.api.pastebin.repositories.PastebinRepository;
import com.mally.api.shared.utils.PaginationUtils;
import io.github.thibaultmeyer.cuid.CUID;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@AllArgsConstructor
Expand All @@ -17,11 +29,13 @@ public class PastebinService {

private final PastebinRepository pastebinRepository;

private final EntityManager entityManager;

public Optional<Paste> findBySlug(String slug) {
return pastebinRepository.findBySlug(slug);
}

public Paste create(CreatePasteDTO dto) {
public Paste create(CreatePasteDTO dto, String userId) {
final ZonedDateTime now = ZonedDateTime.now();
final CUID slug = CUID.randomCUID2(22);

Expand All @@ -30,6 +44,7 @@ public Paste create(CreatePasteDTO dto) {
.syntax(dto.getSyntax())
.slug(slug.toString())
.encrypted(dto.isEncrypted())
.userId(userId)
.createdAt(now)
.expiresAt(now.plusDays(EXPIRES_IN_DAYS))
.build();
Expand All @@ -40,4 +55,26 @@ public Paste create(CreatePasteDTO dto) {
public void deleteExpiredPastes() {
pastebinRepository.deleteExpiredPastes(ZonedDateTime.now());
}

public Long getStats(String userId) {
return pastebinRepository.countByUserId(userId);
}

public Page<Paste> findAll(String userId, String search, Pageable pageable) {
if (search != null && !search.isEmpty()) {
List<String> searchFields = List.of("slug", "text", "syntax");

return PaginationUtils.paginateSearch(entityManager, Paste.class, searchFields, search, userId, pageable);
}

return pastebinRepository.findAllByUserId(userId, pageable);
}

public void delete(Long id) {
pastebinRepository.deleteById(id);
}

public void deleteMany(List<Long> ids) {
pastebinRepository.deleteAllById(ids);
}
}
Loading

0 comments on commit c3c45cd

Please sign in to comment.