Poprawki bezpieczenstwa: maile, kredyty, token strumienia, lokalizacja, spotkania, zgloszenia
Deploy Polska Lokalnie / deploy (push) Successful in 13s
Deploy Polska Lokalnie / deploy (push) Successful in 13s
- Imie, tytul artykulu i powod odrzucenia oraz {{name}} w kampaniach
escapowane w HTML maili; limit dlugosci imienia.
- Kredyty promowania zuzywane i dopisywane warunkowym UPDATE, ITN blokuje
wiersz zamowienia; zakup i kredyt tylko na aktywny plan/pakiet.
- Strumien powiadomien na krotkim (60 s) tokenie ?ticket= pobieranym przez
POST /api/notifications/stream-token; token sesji nie jest przyjmowany z adresu.
- Przyblizona lokalizacja jako srodek kratki 300 m zamiast odwracalnego
przesuniecia liczonego z id.
- Propozycja spotkania respektuje blokade i moderacje, bez e-maila w nazwie.
- Zglaszajacy dostaje samo potwierdzenie; zgloszenia tylko opublikowanych
ogloszen; nie zapisujemy rozmowy przyslanej przez przegladarke.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,7 @@ public class AccountEmailService {
|
|||||||
boolean approved, String reason, String path) {
|
boolean approved, String reason, String path) {
|
||||||
String link = baseUrl + (path == null ? "" : path);
|
String link = baseUrl + (path == null ? "" : path);
|
||||||
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
|
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
|
||||||
String safeTitle = articleTitle == null ? "" : articleTitle;
|
String safeTitle = articleTitle == null ? "" : HtmlUtils.htmlEscape(articleTitle);
|
||||||
String body;
|
String body;
|
||||||
if (approved) {
|
if (approved) {
|
||||||
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
|
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
|
||||||
@@ -80,7 +80,7 @@ public class AccountEmailService {
|
|||||||
} else {
|
} else {
|
||||||
String reasonHtml = reason == null || reason.isBlank() ? ""
|
String reasonHtml = reason == null || reason.isBlank() ? ""
|
||||||
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
|
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
|
||||||
+ reason.trim() + "</p>";
|
+ HtmlUtils.htmlEscape(reason.trim()) + "</p>";
|
||||||
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
|
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
|
||||||
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
|
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
|
||||||
+ reasonHtml
|
+ reasonHtml
|
||||||
@@ -143,8 +143,10 @@ public class AccountEmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String greeting(String name) {
|
// Imie pochodzi z formularza rejestracji (kazdy moze zarejestrowac cudzy adres), wiec jest
|
||||||
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
|
// escapowane - inaczej prawdziwy mail z serwisu niosl by link lub tresc atakujacego.
|
||||||
|
static String greeting(String name) {
|
||||||
|
String who = name == null || name.isBlank() ? "" : " " + HtmlUtils.htmlEscape(name.trim().split("\\s+")[0]);
|
||||||
return "<p>Cześć" + who + ",</p>";
|
return "<p>Cześć" + who + ",</p>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import pl.polskalokalnie.user.UserRepository;
|
|||||||
@Component
|
@Component
|
||||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
static final String STREAM_PATH = "/api/notifications/stream";
|
||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
@@ -35,10 +37,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
@NonNull HttpServletResponse response,
|
@NonNull HttpServletResponse response,
|
||||||
@NonNull FilterChain filterChain
|
@NonNull FilterChain filterChain
|
||||||
) throws ServletException, IOException {
|
) throws ServletException, IOException {
|
||||||
String token = resolveToken(request);
|
String header = request.getHeader("Authorization");
|
||||||
if (token != null) {
|
boolean fromHeader = header != null && header.startsWith("Bearer ");
|
||||||
|
// Token w adresie tylko dla strumienia powiadomien (EventSource nie ustawia naglowkow) i tylko
|
||||||
|
// krotki token strumienia - token sesji w adresie trafial do logow serwera.
|
||||||
|
String token = fromHeader ? header.substring(7)
|
||||||
|
: STREAM_PATH.equals(request.getRequestURI()) ? request.getParameter("ticket") : null;
|
||||||
|
if (token != null && !token.isBlank()) {
|
||||||
try {
|
try {
|
||||||
Claims claims = jwtService.parse(token);
|
Claims claims = jwtService.parse(token);
|
||||||
|
boolean streamToken = JwtService.PURPOSE_STREAM.equals(claims.get("purpose", String.class));
|
||||||
|
if (streamToken == fromHeader) {
|
||||||
|
// Token strumienia nie jest sesja, a sesji nie przyjmujemy z adresu.
|
||||||
|
throw new IllegalArgumentException("Token uzyty niezgodnie z przeznaczeniem");
|
||||||
|
}
|
||||||
// Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
|
// Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
|
||||||
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
|
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
|
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
|
||||||
@@ -64,16 +76,4 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
|
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
|
|
||||||
private String resolveToken(HttpServletRequest request) {
|
|
||||||
String header = request.getHeader("Authorization");
|
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
|
||||||
return header.substring(7);
|
|
||||||
}
|
|
||||||
String param = request.getParameter("access_token");
|
|
||||||
if (param != null && !param.isBlank()) {
|
|
||||||
return param;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ public class JwtService {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Przeznaczenie tokenu strumienia powiadomien (claim "purpose"). */
|
||||||
|
public static final String PURPOSE_STREAM = "sse";
|
||||||
|
private static final long STREAM_TOKEN_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Krotki (60 s) token wylacznie do otwarcia strumienia powiadomien. EventSource nie ustawia
|
||||||
|
* naglowkow, wiec token idzie w adresie - a adresy laduja w logach. Dlatego nie jest to token
|
||||||
|
* sesji: dziala tylko na /api/notifications/stream i wygasa po minucie.
|
||||||
|
*/
|
||||||
|
public String generateStreamToken(String email) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(email)
|
||||||
|
.claim("purpose", PURPOSE_STREAM)
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(now.plusSeconds(STREAM_TOKEN_SECONDS)))
|
||||||
|
.signWith(key)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
public Claims parse(String token) {
|
public Claims parse(String token) {
|
||||||
return Jwts.parser()
|
return Jwts.parser()
|
||||||
.verifyWith(key)
|
.verifyWith(key)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pl.polskalokalnie.user.AccountType;
|
|||||||
public record RegisterRequest(
|
public record RegisterRequest(
|
||||||
@NotBlank @Email String email,
|
@NotBlank @Email String email,
|
||||||
@NotBlank @Size(min = 6, max = 72) String password,
|
@NotBlank @Size(min = 6, max = 72) String password,
|
||||||
@NotBlank String fullName,
|
@NotBlank @Size(max = 120) String fullName,
|
||||||
AccountType accountType,
|
AccountType accountType,
|
||||||
String phone,
|
String phone,
|
||||||
String nip,
|
String nip,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package pl.polskalokalnie.auth.dto;
|
package pl.polskalokalnie.auth.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import pl.polskalokalnie.user.ContactPreference;
|
import pl.polskalokalnie.user.ContactPreference;
|
||||||
import pl.polskalokalnie.user.PreferredLanguage;
|
import pl.polskalokalnie.user.PreferredLanguage;
|
||||||
|
|
||||||
public record UpdateProfileRequest(
|
public record UpdateProfileRequest(
|
||||||
@NotBlank String fullName,
|
@NotBlank @Size(max = 120) String fullName,
|
||||||
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
|
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
|
||||||
String nick,
|
String nick,
|
||||||
String phone,
|
String phone,
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ public record ListingDetailResponse(
|
|||||||
/**
|
/**
|
||||||
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
|
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
|
||||||
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
|
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
|
||||||
* przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej
|
* zastepowane srodkiem kratki siatki, w ktorej lezy adres - dla danej oferty zawsze tym samym,
|
||||||
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
|
* wiec kolo nie skacze po mapie przy kazdym odswiezeniu.
|
||||||
*
|
*
|
||||||
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
|
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
|
||||||
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
|
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
|
||||||
@@ -151,31 +151,33 @@ public record ListingDetailResponse(
|
|||||||
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
|
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Double blurLat(PropertyListing listing) {
|
// Zamiast przesuniecia liczonego z (jawnego) identyfikatora ogloszenia - ktore dalo sie odwrocic
|
||||||
|
// i odzyskac dokladny adres - punkt jest przyciagany do srodka kratki siatki. Z odpowiedzi wiadomo
|
||||||
|
// tylko, w ktorej kratce lezy adres. Kratka ma bok rowny promieniowi kola, wiec najdalszy jej rog
|
||||||
|
// (~0,71 promienia od srodka) zawsze miesci sie w rysowanym obszarze, a punkt nie skacze po mapie.
|
||||||
|
private static final double GRID_CELL_M = APPROXIMATE_RADIUS_M;
|
||||||
|
|
||||||
|
static Double blurLat(PropertyListing listing) {
|
||||||
if (listing.getLat() == null) {
|
if (listing.getLat() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return listing.getLat() + (offsetMeters(listing) * Math.cos(offsetAngle(listing))) / METERS_PER_DEGREE;
|
return snapToCellCenter(listing.getLat(), GRID_CELL_M / METERS_PER_DEGREE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Double blurLng(PropertyListing listing) {
|
static Double blurLng(PropertyListing listing) {
|
||||||
if (listing.getLng() == null || listing.getLat() == null) {
|
if (listing.getLng() == null || listing.getLat() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat()));
|
// Szerokosc kratki w stopniach dlugosci liczona dla srodka kratki szerokosci, a nie dla
|
||||||
if (parallel == 0) {
|
// dokladnej szerokosci adresu - inaczej sam rozmiar kratki zdradzalby dokladne lat.
|
||||||
|
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(blurLat(listing)));
|
||||||
|
if (parallel <= 0) {
|
||||||
return listing.getLng();
|
return listing.getLng();
|
||||||
}
|
}
|
||||||
return listing.getLng() + (offsetMeters(listing) * Math.sin(offsetAngle(listing))) / parallel;
|
return snapToCellCenter(listing.getLng(), GRID_CELL_M / parallel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kierunek i dlugosc przesuniecia sa stale dla ogloszenia, a punkt zostaje w polowie promienia,
|
private static double snapToCellCenter(double value, double cellDegrees) {
|
||||||
// wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola.
|
return (Math.floor(value / cellDegrees) + 0.5) * cellDegrees;
|
||||||
private static double offsetAngle(PropertyListing listing) {
|
|
||||||
return Math.toRadians((listing.getId() * 137L) % 360);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double offsetMeters(PropertyListing listing) {
|
|
||||||
return (listing.getId() * 53L) % (long) (APPROXIMATE_RADIUS_M / 2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ public class MeetingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Conversation conversation = requireConversation(me, request.conversationId());
|
Conversation conversation = requireConversation(me, request.conversationId());
|
||||||
|
// Zablokowany uzytkownik nie moze pisac - propozycja spotkania to tez wiadomosc w rozmowie.
|
||||||
|
conversationService.requireCanWrite(conversation, me);
|
||||||
AppUser other = requireUser(conversation.otherParticipant(me.getId()));
|
AppUser other = requireUser(conversation.otherParticipant(me.getId()));
|
||||||
meeting.setConversationId(conversation.getId());
|
meeting.setConversationId(conversation.getId());
|
||||||
meeting.setListingId(conversation.getListingId());
|
meeting.setListingId(conversation.getListingId());
|
||||||
@@ -212,6 +214,13 @@ public class MeetingService {
|
|||||||
if (request.date().isBefore(LocalDate.now())) {
|
if (request.date().isBefore(LocalDate.now())) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie można umówić spotkania w przeszłości");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie można umówić spotkania w przeszłości");
|
||||||
}
|
}
|
||||||
|
// Limity kolumn w bazie - dluzszy tekst konczyl sie bledem 500 zamiast czytelnym komunikatem.
|
||||||
|
if (request.title().trim().length() > 160
|
||||||
|
|| (request.note() != null && request.note().trim().length() > 200)
|
||||||
|
|| (request.location() != null && request.location().trim().length() > 200)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Za długi tekst - nazwa do 160 znaków, notatka i miejsce do 200 znaków");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Conversation requireConversation(AppUser me, Long conversationId) {
|
private Conversation requireConversation(AppUser me, Long conversationId) {
|
||||||
@@ -261,7 +270,11 @@ public class MeetingService {
|
|||||||
return "Konto usunięte";
|
return "Konto usunięte";
|
||||||
}
|
}
|
||||||
String name = user.getFullName();
|
String name = user.getFullName();
|
||||||
return name == null || name.isBlank() ? user.getEmail() : name;
|
if (name != null && !name.isBlank()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
// Bez imienia - nick, jak w wiadomosciach. Adres e-mail (login) nie trafia do drugiej strony.
|
||||||
|
return user.getNick() != null ? user.getNick() : "Użytkownik";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String trimOrNull(String value) {
|
private static String trimOrNull(String value) {
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ public class ConversationService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void appendMeetingNote(Conversation conversation, AppUser sender, String content) {
|
public void appendMeetingNote(Conversation conversation, AppUser sender, String content) {
|
||||||
|
// Notatka niesie tekst od uzytkownika (miejsce spotkania) - podlega tej samej moderacji co wiadomosc.
|
||||||
|
textModerationService.validateOrThrow(content);
|
||||||
Message message = new Message();
|
Message message = new Message();
|
||||||
message.setConversationId(conversation.getId());
|
message.setConversationId(conversation.getId());
|
||||||
message.setSenderId(sender.getId());
|
message.setSenderId(sender.getId());
|
||||||
@@ -264,6 +266,11 @@ public class ConversationService {
|
|||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Blokada miedzy uczestnikami rozmowy obowiazuje tez poza wiadomosciami (np. propozycje spotkan). */
|
||||||
|
public void requireCanWrite(Conversation conversation, AppUser me) {
|
||||||
|
requireNotBlocked(me.getId(), conversation.otherParticipant(me.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
private void requireNotBlocked(Long meId, Long otherId) {
|
private void requireNotBlocked(Long meId, Long otherId) {
|
||||||
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
|
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
|
||||||
|
|||||||
@@ -16,15 +16,18 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import pl.polskalokalnie.auth.JwtService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/notifications")
|
@RequestMapping("/api/notifications")
|
||||||
public class NotificationController {
|
public class NotificationController {
|
||||||
|
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
public NotificationController(NotificationService notificationService) {
|
public NotificationController(NotificationService notificationService, JwtService jwtService) {
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
|
this.jwtService = jwtService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -86,6 +89,15 @@ public class NotificationController {
|
|||||||
return created == null ? null : NotificationResponse.from(created);
|
return created == null ? null : NotificationResponse.from(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Krotki token do otwarcia strumienia (EventSource przekazuje go w adresie, nie w naglowku). */
|
||||||
|
@PostMapping("/stream-token")
|
||||||
|
public StreamTokenResponse streamToken(Authentication authentication) {
|
||||||
|
return new StreamTokenResponse(jwtService.generateStreamToken(authentication.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StreamTokenResponse(String token) {
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public SseEmitter stream(Authentication authentication) {
|
public SseEmitter stream(Authentication authentication) {
|
||||||
return notificationService.subscribe(authentication.getName());
|
return notificationService.subscribe(authentication.getName());
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ public class AutoPayItnService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String process(AutoPayItnParser.ItnTransaction tx) {
|
private String process(AutoPayItnParser.ItnTransaction tx) {
|
||||||
Optional<PromotionOrder> found = orderRepository.findByExternalId(tx.orderId());
|
Optional<PromotionOrder> found = orderRepository.findForUpdateByExternalId(tx.orderId());
|
||||||
if (found.isEmpty()) {
|
if (found.isEmpty()) {
|
||||||
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
|
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
|
||||||
return NOT_CONFIRMED;
|
return NOT_CONFIRMED;
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
package pl.polskalokalnie.promotion;
|
package pl.polskalokalnie.promotion;
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
|
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
|
||||||
Optional<PromotionOrder> findByExternalId(String externalId);
|
Optional<PromotionOrder> findByExternalId(String externalId);
|
||||||
List<PromotionOrder> findByOrderByCreatedAtDesc();
|
List<PromotionOrder> findByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zamowienie z blokada wiersza do konca transakcji - rownolegle notyfikacje ITN o tym samym
|
||||||
|
* zamowieniu obsluguja sie po kolei, wiec kredyty nie zostana naliczone dwa razy.
|
||||||
|
*/
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select o from PromotionOrder o where o.externalId = :externalId")
|
||||||
|
Optional<PromotionOrder> findForUpdateByExternalId(@Param("externalId") String externalId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ public class PromotionService {
|
|||||||
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
|
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
|
||||||
|
|
||||||
if (request.kind() == PromotionKind.PLAN) {
|
if (request.kind() == PromotionKind.PLAN) {
|
||||||
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
|
PromotionPlan plan = requireActivePlan(request.planId());
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
|
||||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||||
order.setRefId(plan.getId());
|
order.setRefId(plan.getId());
|
||||||
order.setItemName("Promowanie: " + plan.getName());
|
order.setItemName("Promowanie: " + plan.getName());
|
||||||
@@ -88,6 +87,7 @@ public class PromotionService {
|
|||||||
order.setListingId(listing.getId());
|
order.setListingId(listing.getId());
|
||||||
} else {
|
} else {
|
||||||
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
|
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
|
||||||
|
.filter(PromotionPackage::isActive)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||||
order.setRefId(pack.getId());
|
order.setRefId(pack.getId());
|
||||||
order.setItemName("Pakiet promowan: " + pack.getName());
|
order.setItemName("Pakiet promowan: " + pack.getName());
|
||||||
@@ -151,8 +151,7 @@ public class PromotionService {
|
|||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
|
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||||
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
|
userRepository.addPromotionCredits(user.getId(), pack.getQuantity());
|
||||||
userRepository.save(user);
|
|
||||||
}
|
}
|
||||||
order.setStatus(OrderStatus.PAID);
|
order.setStatus(OrderStatus.PAID);
|
||||||
order.setPaidAt(Instant.now());
|
order.setPaidAt(Instant.now());
|
||||||
@@ -163,15 +162,13 @@ public class PromotionService {
|
|||||||
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||||
if (user.getPromotionCredits() <= 0) {
|
PromotionPlan plan = requireActivePlan(request.planId());
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
|
||||||
}
|
|
||||||
PromotionPlan plan = planRepository.findById(request.planId())
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
|
||||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||||
|
|
||||||
user.setPromotionCredits(user.getPromotionCredits() - 1);
|
// Sprawdzenie i zuzycie kredytu w jednym zapytaniu - rownolegle zadania nie wydadza go dwa razy.
|
||||||
userRepository.save(user);
|
if (userRepository.consumePromotionCredit(user.getId()) == 0) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
||||||
|
}
|
||||||
extendPromotion(listing, plan.getDurationDays());
|
extendPromotion(listing, plan.getDurationDays());
|
||||||
listingRepository.save(listing);
|
listingRepository.save(listing);
|
||||||
|
|
||||||
@@ -188,7 +185,15 @@ public class PromotionService {
|
|||||||
order.setPaidAt(Instant.now());
|
order.setPaidAt(Instant.now());
|
||||||
orderRepository.save(order);
|
orderRepository.save(order);
|
||||||
|
|
||||||
return user.getPromotionCredits();
|
return credits(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kupic (i wydac na nie kredyt) mozna tylko plan wystawiony w ofercie - wylaczony przez
|
||||||
|
// administracje plan (np. o starej cenie) jest niedostepny takze przy wywolaniu API wprost.
|
||||||
|
private PromotionPlan requireActivePlan(Long planId) {
|
||||||
|
return planRepository.findById(requireId(planId, "planId"))
|
||||||
|
.filter(PromotionPlan::isActive)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public String orderStatus(String email, Long id) {
|
public String orderStatus(String email, Long id) {
|
||||||
|
|||||||
@@ -19,20 +19,24 @@ public class ListingReportController {
|
|||||||
this.userReportService = userReportService;
|
this.userReportService = userReportService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zglaszajacy dostaje samo potwierdzenie - pelne zgloszenie (e-mail wlasciciela, dane
|
||||||
|
// zgloszonego) widzi tylko administracja.
|
||||||
@PostMapping("/listings")
|
@PostMapping("/listings")
|
||||||
public ListingReportResponse createListingReport(
|
public ReportReceiptResponse createListingReport(
|
||||||
@Valid @RequestBody CreateListingReportRequest request,
|
@Valid @RequestBody CreateListingReportRequest request,
|
||||||
Authentication authentication
|
Authentication authentication
|
||||||
) {
|
) {
|
||||||
return listingReportService.create(request, authentication.getName());
|
ListingReportResponse report = listingReportService.create(request, authentication.getName());
|
||||||
|
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zgloszenie uzytkownika z rozmowy w wiadomosciach.
|
// Zgloszenie uzytkownika z rozmowy w wiadomosciach.
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
public UserReportResponse createUserReport(
|
public ReportReceiptResponse createUserReport(
|
||||||
@Valid @RequestBody CreateUserReportRequest request,
|
@Valid @RequestBody CreateUserReportRequest request,
|
||||||
Authentication authentication
|
Authentication authentication
|
||||||
) {
|
) {
|
||||||
return userReportService.create(request, authentication.getName());
|
UserReportResponse report = userReportService.create(request, authentication.getName());
|
||||||
|
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.listing.ListingRepository;
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
import pl.polskalokalnie.listing.PropertyListing;
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
@@ -30,7 +31,10 @@ public class ListingReportService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
|
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
|
||||||
|
// Zglaszac mozna tylko opublikowane ogloszenia - inaczej kolejne numery pozwalaly sprawdzac
|
||||||
|
// istnienie ogloszen oczekujacych i odrzuconych.
|
||||||
PropertyListing listing = listingRepository.findById(request.listingId())
|
PropertyListing listing = listingRepository.findById(request.listingId())
|
||||||
|
.filter(found -> found.getStatus() == ListingStatus.APPROVED)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
||||||
|
|
||||||
ListingReport report = new ListingReport();
|
ListingReport report = new ListingReport();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package pl.polskalokalnie.report;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Potwierdzenie przyjecia zgloszenia dla zglaszajacego. Celowo bez danych zgloszonego (e-mail,
|
||||||
|
* nazwa, czy ma konto) i ogloszenia - pelne zgloszenie widzi tylko administracja.
|
||||||
|
*/
|
||||||
|
public record ReportReceiptResponse(Long id, Instant createdAt, String reasonTitle, ListingReportStatus status) {
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ public class UserReportService {
|
|||||||
|
|
||||||
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
|
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
|
||||||
private static final int MAX_CONVERSATION_MESSAGES = 50;
|
private static final int MAX_CONVERSATION_MESSAGES = 50;
|
||||||
private static final int MAX_MESSAGE_LENGTH = 2000;
|
|
||||||
|
|
||||||
private final UserReportRepository userReportRepository;
|
private final UserReportRepository userReportRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
@@ -42,7 +41,6 @@ public class UserReportService {
|
|||||||
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
|
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
|
||||||
report.setListingId(request.listingId());
|
report.setListingId(request.listingId());
|
||||||
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
|
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
|
||||||
report.setConversation(cleanConversation(request.conversation()));
|
|
||||||
report.setReasonId(request.reasonId().trim());
|
report.setReasonId(request.reasonId().trim());
|
||||||
report.setReasonTitle(request.reasonTitle().trim());
|
report.setReasonTitle(request.reasonTitle().trim());
|
||||||
report.setDetails(ReportAttachments.trimToNull(request.details()));
|
report.setDetails(ReportAttachments.trimToNull(request.details()));
|
||||||
@@ -53,7 +51,8 @@ public class UserReportService {
|
|||||||
|
|
||||||
// Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
|
// Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
|
||||||
// a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
|
// a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
|
||||||
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje.
|
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje, a tresci rozmowy
|
||||||
|
// przyslanej przez przegladarke nie zapisujemy - administracja wzielaby ja za dowod.
|
||||||
AppUser reporter = userRepository.findByEmailIgnoreCase(reporterEmail).orElse(null);
|
AppUser reporter = userRepository.findByEmailIgnoreCase(reporterEmail).orElse(null);
|
||||||
Conversation conversation = reporterConversation(request.conversationId(), reporter);
|
Conversation conversation = reporterConversation(request.conversationId(), reporter);
|
||||||
AppUser reported;
|
AppUser reported;
|
||||||
@@ -115,34 +114,6 @@ public class UserReportService {
|
|||||||
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
|
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
|
|
||||||
private static List<UserReportMessage> cleanConversation(List<UserReportMessagePayload> payloads) {
|
|
||||||
if (payloads == null || payloads.isEmpty()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
List<UserReportMessagePayload> tail = payloads.size() > MAX_CONVERSATION_MESSAGES
|
|
||||||
? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size())
|
|
||||||
: payloads;
|
|
||||||
|
|
||||||
List<UserReportMessage> cleaned = new ArrayList<>();
|
|
||||||
for (UserReportMessagePayload payload : tail) {
|
|
||||||
if (payload == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String content = payload.content() == null ? "" : payload.content().trim();
|
|
||||||
if (content.length() > MAX_MESSAGE_LENGTH) {
|
|
||||||
content = content.substring(0, MAX_MESSAGE_LENGTH);
|
|
||||||
}
|
|
||||||
UserReportMessage message = new UserReportMessage();
|
|
||||||
message.setFromReporter(payload.fromReporter());
|
|
||||||
message.setContent(content);
|
|
||||||
message.setSentAt(payload.sentAt());
|
|
||||||
message.setHasPhoto(payload.hasPhoto());
|
|
||||||
cleaned.add(message);
|
|
||||||
}
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
|
// Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
|
||||||
private Conversation reporterConversation(String conversationId, AppUser reporter) {
|
private Conversation reporterConversation(String conversationId, AppUser reporter) {
|
||||||
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
|
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ public class MessageComposer {
|
|||||||
|
|
||||||
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
|
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
|
||||||
public String composeEmail(String rawBody, String recipientName, Long leadId) {
|
public String composeEmail(String rawBody, String recipientName, Long leadId) {
|
||||||
String personalized = personalize(rawBody, recipientName);
|
// O formacie decyduje tresc kampanii, a nie podstawione imie. Imie pochodzi m.in. z publicznego
|
||||||
String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized);
|
// formularza, wiec do tresci HTML trafia escapowane - inaczej wstrzyknelo by link do maila.
|
||||||
|
String html = looksLikeHtml(rawBody)
|
||||||
|
? personalize(rawBody, recipientName == null ? null : escapeHtml(recipientName))
|
||||||
|
: textToHtml(personalize(rawBody, recipientName));
|
||||||
String link = unsubscribeLink(leadId, "email");
|
String link = unsubscribeLink(leadId, "email");
|
||||||
String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
|
String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
|
||||||
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
|
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
|
||||||
@@ -64,6 +67,7 @@ public class MessageComposer {
|
|||||||
return value
|
return value
|
||||||
.replace("&", "&")
|
.replace("&", "&")
|
||||||
.replace("<", "<")
|
.replace("<", "<")
|
||||||
.replace(">", ">");
|
.replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package pl.polskalokalnie.user;
|
|||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<AppUser, Long> {
|
public interface UserRepository extends JpaRepository<AppUser, Long> {
|
||||||
|
|
||||||
@@ -16,4 +19,17 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
|
|||||||
Optional<AppUser> findFirstByRole(Role role);
|
Optional<AppUser> findFirstByRole(Role role);
|
||||||
|
|
||||||
long countByRole(Role role);
|
long countByRole(Role role);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuzycie jednego kredytu promowania jednym zapytaniem z warunkiem - dwa rownolegle zadania
|
||||||
|
* nie wydadza tego samego kredytu dwa razy. Zwraca 0, gdy kredytow juz nie ma.
|
||||||
|
*/
|
||||||
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
|
@Query("update AppUser u set u.promotionCredits = u.promotionCredits - 1 where u.id = :id and u.promotionCredits > 0")
|
||||||
|
int consumePromotionCredit(@Param("id") Long id);
|
||||||
|
|
||||||
|
/** Dopisanie kredytow bez odczytu i zapisu calego konta (nie nadpisze rownoleglego zuzycia). */
|
||||||
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
|
@Query("update AppUser u set u.promotionCredits = u.promotionCredits + :amount where u.id = :id")
|
||||||
|
int addPromotionCredits(@Param("id") Long id, @Param("amount") int amount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package pl.polskalokalnie.auth;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class AccountEmailServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void greetingEscapesNameFromRegistrationForm() {
|
||||||
|
// Regresja: rejestracja na cudzy e-mail z linkiem w polu imienia dawala prawdziwy mail
|
||||||
|
// aktywacyjny z serwisu z linkiem atakujacego.
|
||||||
|
String html = AccountEmailService.greeting("<a/href=https://evil.example>Kliknij</a>");
|
||||||
|
|
||||||
|
assertThat(html).doesNotContain("<a").contains("<a/href=");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void greetingUsesFirstName() {
|
||||||
|
assertThat(AccountEmailService.greeting("Jan Kowalski")).isEqualTo("<p>Cześć Jan,</p>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
package pl.polskalokalnie.auth;
|
package pl.polskalokalnie.auth;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.Role;
|
import pl.polskalokalnie.user.Role;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
|
||||||
class JwtAuthFilterTest {
|
class JwtAuthFilterTest {
|
||||||
|
|
||||||
@@ -16,6 +23,60 @@ class JwtAuthFilterTest {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final JwtService jwtService = new JwtService("test-secret-key-for-junit-context-32chars-min", 3600);
|
||||||
|
private final UserRepository userRepository = mock(UserRepository.class);
|
||||||
|
private final JwtAuthFilter filter = new JwtAuthFilter(jwtService, userRepository);
|
||||||
|
|
||||||
|
/** Czy zadanie przeszlo przez filtr jako uwierzytelnione. */
|
||||||
|
private boolean authenticated(MockHttpServletRequest request) throws Exception {
|
||||||
|
AppUser user = user();
|
||||||
|
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(user));
|
||||||
|
boolean[] result = new boolean[1];
|
||||||
|
filter.doFilter(request, new MockHttpServletResponse(),
|
||||||
|
(req, res) -> result[0] = SecurityContextHolder.getContext().getAuthentication() != null);
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequest get(String uri) {
|
||||||
|
return new MockHttpServletRequest("GET", uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sessionTokenWorksOnlyInHeader() throws Exception {
|
||||||
|
String session = jwtService.generateToken(user());
|
||||||
|
|
||||||
|
MockHttpServletRequest header = get("/api/auth/me");
|
||||||
|
header.addHeader("Authorization", "Bearer " + session);
|
||||||
|
assertThat(authenticated(header)).isTrue();
|
||||||
|
|
||||||
|
// Regresja: token sesji w adresie (?access_token=) trafial do logow serwera.
|
||||||
|
MockHttpServletRequest inUrl = get("/api/auth/me");
|
||||||
|
inUrl.setParameter("access_token", session);
|
||||||
|
assertThat(authenticated(inUrl)).isFalse();
|
||||||
|
|
||||||
|
MockHttpServletRequest asTicket = get(JwtAuthFilter.STREAM_PATH);
|
||||||
|
asTicket.setParameter("ticket", session);
|
||||||
|
assertThat(authenticated(asTicket)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamTokenWorksOnlyForNotificationStream() throws Exception {
|
||||||
|
String ticket = jwtService.generateStreamToken("user@example.com");
|
||||||
|
|
||||||
|
MockHttpServletRequest stream = get(JwtAuthFilter.STREAM_PATH);
|
||||||
|
stream.setParameter("ticket", ticket);
|
||||||
|
assertThat(authenticated(stream)).isTrue();
|
||||||
|
|
||||||
|
MockHttpServletRequest otherPath = get("/api/auth/me");
|
||||||
|
otherPath.setParameter("ticket", ticket);
|
||||||
|
assertThat(authenticated(otherPath)).isFalse();
|
||||||
|
|
||||||
|
MockHttpServletRequest asSession = get("/api/auth/me");
|
||||||
|
asSession.addHeader("Authorization", "Bearer " + ticket);
|
||||||
|
assertThat(authenticated(asSession)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void activeUserWithFreshTokenIsAccepted() {
|
void activeUserWithFreshTokenIsAccepted() {
|
||||||
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
|
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
|
||||||
|
|||||||
@@ -14,6 +14,27 @@ class ListingPrivacyTest {
|
|||||||
return listing;
|
return listing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approximateLocationStaysInsideCircleAndDoesNotDependOnListingId() {
|
||||||
|
// Regresja: przesuniecie liczone z jawnego id dalo sie odwrocic i odzyskac dokladny adres.
|
||||||
|
PropertyListing a = listing();
|
||||||
|
a.setLat(52.229676);
|
||||||
|
a.setLng(21.012229);
|
||||||
|
PropertyListing b = listing();
|
||||||
|
b.setLat(52.229700);
|
||||||
|
b.setLng(21.012300);
|
||||||
|
|
||||||
|
double lat = ListingDetailResponse.blurLat(a);
|
||||||
|
double lng = ListingDetailResponse.blurLng(a);
|
||||||
|
// Dwa rozne adresy w tej samej kratce daja ten sam punkt - nie da sie z niego wrocic do adresu.
|
||||||
|
assertThat(ListingDetailResponse.blurLat(b)).isEqualTo(lat);
|
||||||
|
assertThat(ListingDetailResponse.blurLng(b)).isEqualTo(lng);
|
||||||
|
|
||||||
|
double dLatM = (lat - a.getLat()) * 111_320d;
|
||||||
|
double dLngM = (lng - a.getLng()) * 111_320d * Math.cos(Math.toRadians(a.getLat()));
|
||||||
|
assertThat(Math.hypot(dLatM, dLngM)).isLessThan(300d);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void publicListDoesNotExposeOwnerEmail() {
|
void publicListDoesNotExposeOwnerEmail() {
|
||||||
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
|
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class AutoPayItnServiceTest {
|
|||||||
order = new PromotionOrder();
|
order = new PromotionOrder();
|
||||||
order.setExternalId(ORDER_ID);
|
order.setExternalId(ORDER_ID);
|
||||||
order.setAmount(new BigDecimal("49.99"));
|
order.setAmount(new BigDecimal("49.99"));
|
||||||
when(orderRepository.findByExternalId(ORDER_ID)).thenReturn(Optional.of(order));
|
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.of(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String itn(String amount, String currency, String status) {
|
private String itn(String amount, String currency, String status) {
|
||||||
@@ -178,7 +178,7 @@ class AutoPayItnServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsUnknownOrderId() {
|
void rejectsUnknownOrderId() {
|
||||||
when(orderRepository.findByExternalId(ORDER_ID)).thenReturn(Optional.empty());
|
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
|
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.listing.ListingRepository;
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
import pl.polskalokalnie.payment.AutoPayService;
|
import pl.polskalokalnie.payment.AutoPayService;
|
||||||
import pl.polskalokalnie.payment.PaymentConfig;
|
import pl.polskalokalnie.payment.PaymentConfig;
|
||||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||||
@@ -26,13 +27,60 @@ class PaymentSecurityTest {
|
|||||||
private final PromotionOrderRepository orderRepository = mock(PromotionOrderRepository.class);
|
private final PromotionOrderRepository orderRepository = mock(PromotionOrderRepository.class);
|
||||||
private final UserRepository userRepository = mock(UserRepository.class);
|
private final UserRepository userRepository = mock(UserRepository.class);
|
||||||
private final PaymentConfigService paymentConfigService = mock(PaymentConfigService.class);
|
private final PaymentConfigService paymentConfigService = mock(PaymentConfigService.class);
|
||||||
|
private final PromotionPlanRepository planRepository = mock(PromotionPlanRepository.class);
|
||||||
|
private final ListingRepository listingRepository = mock(ListingRepository.class);
|
||||||
|
|
||||||
private PromotionService service(boolean autoConfirm) {
|
private PromotionService service(boolean autoConfirm) {
|
||||||
return new PromotionService(mock(PromotionPlanRepository.class), packageRepository, orderRepository,
|
return new PromotionService(planRepository, packageRepository, orderRepository,
|
||||||
userRepository, mock(ListingRepository.class), paymentConfigService, mock(AutoPayService.class),
|
userRepository, listingRepository, paymentConfigService, mock(AutoPayService.class),
|
||||||
autoConfirm);
|
autoConfirm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PropertyListing ownListing() {
|
||||||
|
PropertyListing listing = new PropertyListing();
|
||||||
|
listing.setOwnerEmail("user@example.com");
|
||||||
|
when(listingRepository.findById(10L)).thenReturn(Optional.of(listing));
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PromotionPlan plan(boolean active) {
|
||||||
|
PromotionPlan plan = new PromotionPlan();
|
||||||
|
plan.setName("Plan");
|
||||||
|
plan.setDurationDays(30);
|
||||||
|
plan.setPrice(new BigDecimal("89.99"));
|
||||||
|
plan.setActive(active);
|
||||||
|
when(planRepository.findById(3L)).thenReturn(Optional.of(plan));
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void creditIsNotSpentTwice() {
|
||||||
|
// Regresja: odczyt-sprawdzenie-zapis salda pozwalal rownoleglym zadaniom wydac 1 kredyt kilka razy.
|
||||||
|
// Teraz o wydaniu decyduje warunkowy UPDATE w bazie - gdy nie zmieni wiersza, promowania nie ma.
|
||||||
|
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
|
||||||
|
ownListing();
|
||||||
|
plan(true);
|
||||||
|
when(userRepository.consumePromotionCredit(any())).thenReturn(0);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
|
||||||
|
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||||
|
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
|
||||||
|
verify(listingRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inactivePlanCannotBeBoughtOrPaidWithCredit() {
|
||||||
|
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
|
||||||
|
ownListing();
|
||||||
|
plan(false);
|
||||||
|
when(userRepository.consumePromotionCredit(any())).thenReturn(1);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
|
||||||
|
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||||
|
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
|
||||||
|
verify(userRepository, never()).consumePromotionCredit(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void checkoutWithoutGatewayIsRefusedInsteadOfBeingFree() {
|
void checkoutWithoutGatewayIsRefusedInsteadOfBeingFree() {
|
||||||
// Regresja: bez skonfigurowanej bramki zamowienie bylo od razu oplacone - darmowe kredyty dla kazdego.
|
// Regresja: bez skonfigurowanej bramki zamowienie bylo od razu oplacone - darmowe kredyty dla kazdego.
|
||||||
@@ -60,7 +108,7 @@ class PaymentSecurityTest {
|
|||||||
|
|
||||||
service(true).checkout("user@example.com", new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay"));
|
service(true).checkout("user@example.com", new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay"));
|
||||||
|
|
||||||
assertThat(user.getPromotionCredits()).isEqualTo(5);
|
verify(userRepository).addPromotionCredits(user.getId(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pl.polskalokalnie.send;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||||
|
|
||||||
|
class MessageComposerTest {
|
||||||
|
|
||||||
|
private final UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
|
||||||
|
private final MessageComposer composer = new MessageComposer(unsubscribeService, "https://polskalokalnie.pl");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nameInHtmlCampaignIsEscaped() {
|
||||||
|
// Regresja: imie z publicznego formularza trafialo do maila kampanii jako HTML.
|
||||||
|
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
|
||||||
|
|
||||||
|
String html = composer.composeEmail("<p>Dzień dobry {{name}}!</p>", "<a href=\"https://evil\">X</a>", 1L);
|
||||||
|
|
||||||
|
assertThat(html).doesNotContain("href=\"https://evil\"").contains("<a href="https://evil">");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nameInPlainTextCampaignIsEscaped() {
|
||||||
|
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
|
||||||
|
|
||||||
|
String html = composer.composeEmail("Dzień dobry {{name}}!", "<b>Jan</b>", 1L);
|
||||||
|
|
||||||
|
assertThat(html).doesNotContain("<b>Jan</b>").contains("<b>Jan</b>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,8 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
|||||||
.catch(() => { /* cicho - brak sieci/tokenu */ });
|
.catch(() => { /* cicho - brak sieci/tokenu */ });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Ładowanie listy + realtime (SSE) gdy użytkownik zalogowany. Token w query param (EventSource nie ustawia nagłówków).
|
// Ładowanie listy + realtime (SSE) gdy użytkownik zalogowany. EventSource nie ustawia nagłówków,
|
||||||
|
// więc w adresie idzie krótki (60 s) token samego strumienia - nie token sesji, który trafiałby do logów.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!user || !token) {
|
if (!user || !token) {
|
||||||
@@ -118,22 +119,52 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
const source = new EventSource(`/api/notifications/stream?access_token=${encodeURIComponent(token)}`);
|
let disposed = false;
|
||||||
source.addEventListener('notification', (event) => {
|
let source: EventSource | null = null;
|
||||||
|
let reconnectId: number | undefined;
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
try {
|
try {
|
||||||
const incoming = JSON.parse((event as MessageEvent).data) as ServerNotification;
|
const { token: ticket } = await apiFetch<{ token: string }>('/notifications/stream-token', { method: 'POST' });
|
||||||
setNotifications((current) => (current.some((item) => item.id === incoming.id) ? current : [incoming, ...current]));
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source = new EventSource(`/api/notifications/stream?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
source.addEventListener('notification', (event) => {
|
||||||
|
try {
|
||||||
|
const incoming = JSON.parse((event as MessageEvent).data) as ServerNotification;
|
||||||
|
setNotifications((current) => (current.some((item) => item.id === incoming.id) ? current : [incoming, ...current]));
|
||||||
|
} catch {
|
||||||
|
/* ignoruj nieparsowalne zdarzenie */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Token strumienia wygasa po minucie, więc samoczynne wznowienie EventSource pod tym samym
|
||||||
|
// adresem by nie zadziałało - po zerwaniu łączymy się od nowa z nowym tokenem.
|
||||||
|
source.onerror = () => {
|
||||||
|
source?.close();
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
/* ignoruj nieparsowalne zdarzenie */
|
scheduleReconnect();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
// onerror: EventSource sam ponawia połączenie.
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (!disposed) {
|
||||||
|
window.clearTimeout(reconnectId);
|
||||||
|
reconnectId = window.setTimeout(() => { void connect(); }, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void connect();
|
||||||
|
|
||||||
// Zapasowy polling na wypadek zerwanego SSE.
|
// Zapasowy polling na wypadek zerwanego SSE.
|
||||||
const pollId = window.setInterval(refresh, 60000);
|
const pollId = window.setInterval(refresh, 60000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
source.close();
|
disposed = true;
|
||||||
|
source?.close();
|
||||||
|
window.clearTimeout(reconnectId);
|
||||||
window.clearInterval(pollId);
|
window.clearInterval(pollId);
|
||||||
};
|
};
|
||||||
}, [user, refresh]);
|
}, [user, refresh]);
|
||||||
|
|||||||
Reference in New Issue
Block a user