From b850e4c0922bf0fe5acdc67d8d8c18ab6633f0ae Mon Sep 17 00:00:00 2001
From: Przemek Zatorski
Date: Wed, 23 Sep 2026 14:36:48 +0200
Subject: [PATCH] Poprawki bezpieczenstwa: maile, kredyty, token strumienia,
lokalizacja, spotkania, zgloszenia
- 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)
---
.../auth/AccountEmailService.java | 10 +--
.../pl/polskalokalnie/auth/JwtAuthFilter.java | 28 ++++-----
.../pl/polskalokalnie/auth/JwtService.java | 20 ++++++
.../auth/dto/RegisterRequest.java | 2 +-
.../auth/dto/UpdateProfileRequest.java | 3 +-
.../listing/ListingDetailResponse.java | 34 ++++++-----
.../meeting/MeetingService.java | 15 ++++-
.../message/ConversationService.java | 7 +++
.../notification/NotificationController.java | 14 ++++-
.../promotion/AutoPayItnService.java | 2 +-
.../promotion/PromotionOrderRepository.java | 12 ++++
.../promotion/PromotionService.java | 29 +++++----
.../report/ListingReportController.java | 12 ++--
.../report/ListingReportService.java | 4 ++
.../report/ReportReceiptResponse.java | 10 +++
.../report/UserReportService.java | 33 +---------
.../polskalokalnie/send/MessageComposer.java | 10 ++-
.../polskalokalnie/user/UserRepository.java | 16 +++++
.../auth/AccountEmailServiceTest.java | 22 +++++++
.../auth/JwtAuthFilterTest.java | 61 +++++++++++++++++++
.../listing/ListingPrivacyTest.java | 21 +++++++
.../promotion/AutoPayItnServiceTest.java | 4 +-
.../promotion/PaymentSecurityTest.java | 54 +++++++++++++++-
.../send/MessageComposerTest.java | 34 +++++++++++
frontend/src/notifications.tsx | 49 ++++++++++++---
25 files changed, 403 insertions(+), 103 deletions(-)
create mode 100644 backend/src/main/java/pl/polskalokalnie/report/ReportReceiptResponse.java
create mode 100644 backend/src/test/java/pl/polskalokalnie/auth/AccountEmailServiceTest.java
create mode 100644 backend/src/test/java/pl/polskalokalnie/send/MessageComposerTest.java
diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java
index 85c3b47..603a615 100644
--- a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java
+++ b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java
@@ -71,7 +71,7 @@ public class AccountEmailService {
boolean approved, String reason, String path) {
String link = baseUrl + (path == null ? "" : path);
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;
if (approved) {
body = "Dobra wiadomość! Twoja propozycja artykułu „" + safeTitle + "” została"
@@ -80,7 +80,7 @@ public class AccountEmailService {
} else {
String reasonHtml = reason == null || reason.isBlank() ? ""
: "
Powód: "
- + reason.trim() + "
";
+ + HtmlUtils.htmlEscape(reason.trim()) + "
";
body = "Dziękujemy za propozycję artykułu „" + safeTitle + "”. Po analizie nasz zespół"
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.
"
+ reasonHtml
@@ -143,8 +143,10 @@ public class AccountEmailService {
}
}
- private static String greeting(String name) {
- String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
+ // Imie pochodzi z formularza rejestracji (kazdy moze zarejestrowac cudzy adres), wiec jest
+ // 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 "Cześć" + who + ",
";
}
diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java
index f9c35d2..9d48d77 100644
--- a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java
+++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java
@@ -21,6 +21,8 @@ import pl.polskalokalnie.user.UserRepository;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
+ static final String STREAM_PATH = "/api/notifications/stream";
+
private final JwtService jwtService;
private final UserRepository userRepository;
@@ -35,10 +37,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
- String token = resolveToken(request);
- if (token != null) {
+ String header = request.getHeader("Authorization");
+ 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 {
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
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
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));
}
- // 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;
- }
}
diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtService.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtService.java
index 3aacb39..5497ae1 100644
--- a/backend/src/main/java/pl/polskalokalnie/auth/JwtService.java
+++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtService.java
@@ -38,6 +38,26 @@ public class JwtService {
.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) {
return Jwts.parser()
.verifyWith(key)
diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java
index 695fe19..5a12f45 100644
--- a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java
+++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java
@@ -8,7 +8,7 @@ import pl.polskalokalnie.user.AccountType;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 6, max = 72) String password,
- @NotBlank String fullName,
+ @NotBlank @Size(max = 120) String fullName,
AccountType accountType,
String phone,
String nip,
diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java
index 919bf9a..08da851 100644
--- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java
+++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java
@@ -1,11 +1,12 @@
package pl.polskalokalnie.auth.dto;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
import pl.polskalokalnie.user.ContactPreference;
import pl.polskalokalnie.user.PreferredLanguage;
public record UpdateProfileRequest(
- @NotBlank String fullName,
+ @NotBlank @Size(max = 120) String fullName,
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
String nick,
String phone,
diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java
index 7e38123..4a08a98 100644
--- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java
+++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java
@@ -78,8 +78,8 @@ public record ListingDetailResponse(
/**
* 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
- * przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej
- * oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
+ * zastepowane srodkiem kratki siatki, w ktorej lezy adres - dla danej oferty zawsze tym samym,
+ * wiec kolo nie skacze po mapie przy kazdym odswiezeniu.
*
* 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
@@ -151,31 +151,33 @@ public record ListingDetailResponse(
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) {
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) {
return null;
}
- double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat()));
- if (parallel == 0) {
+ // Szerokosc kratki w stopniach dlugosci liczona dla srodka kratki szerokosci, a nie dla
+ // 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() + (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,
- // wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola.
- 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);
+ private static double snapToCellCenter(double value, double cellDegrees) {
+ return (Math.floor(value / cellDegrees) + 0.5) * cellDegrees;
}
}
diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java
index 7cdbb51..f6fa1ef 100644
--- a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java
+++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java
@@ -90,6 +90,8 @@ public class MeetingService {
}
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()));
meeting.setConversationId(conversation.getId());
meeting.setListingId(conversation.getListingId());
@@ -212,6 +214,13 @@ public class MeetingService {
if (request.date().isBefore(LocalDate.now())) {
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) {
@@ -261,7 +270,11 @@ public class MeetingService {
return "Konto usunięte";
}
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) {
diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java
index ffe87ff..c330242 100644
--- a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java
+++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java
@@ -174,6 +174,8 @@ public class ConversationService {
*/
@Transactional
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.setConversationId(conversation.getId());
message.setSenderId(sender.getId());
@@ -264,6 +266,11 @@ public class ConversationService {
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) {
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java
index 83d5efe..999552f 100644
--- a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java
+++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java
@@ -16,15 +16,18 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import pl.polskalokalnie.auth.JwtService;
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationService notificationService;
+ private final JwtService jwtService;
- public NotificationController(NotificationService notificationService) {
+ public NotificationController(NotificationService notificationService, JwtService jwtService) {
this.notificationService = notificationService;
+ this.jwtService = jwtService;
}
@GetMapping
@@ -86,6 +89,15 @@ public class NotificationController {
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)
public SseEmitter stream(Authentication authentication) {
return notificationService.subscribe(authentication.getName());
diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java
index eb8eb1e..1669636 100644
--- a/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java
+++ b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnService.java
@@ -84,7 +84,7 @@ public class AutoPayItnService {
}
private String process(AutoPayItnParser.ItnTransaction tx) {
- Optional found = orderRepository.findByExternalId(tx.orderId());
+ Optional found = orderRepository.findForUpdateByExternalId(tx.orderId());
if (found.isEmpty()) {
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
return NOT_CONFIRMED;
diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrderRepository.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrderRepository.java
index df5ef09..ed89f1a 100644
--- a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrderRepository.java
+++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrderRepository.java
@@ -1,10 +1,22 @@
package pl.polskalokalnie.promotion;
+import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
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 {
Optional findByExternalId(String externalId);
List 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 findForUpdateByExternalId(@Param("externalId") String externalId);
}
diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java
index 3e89278..10bf01c 100644
--- a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java
+++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java
@@ -79,8 +79,7 @@ public class PromotionService {
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
if (request.kind() == PromotionKind.PLAN) {
- PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
+ PromotionPlan plan = requireActivePlan(request.planId());
PropertyListing listing = requireOwnedListing(request.listingId(), email);
order.setRefId(plan.getId());
order.setItemName("Promowanie: " + plan.getName());
@@ -88,6 +87,7 @@ public class PromotionService {
order.setListingId(listing.getId());
} else {
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
+ .filter(PromotionPackage::isActive)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
order.setRefId(pack.getId());
order.setItemName("Pakiet promowan: " + pack.getName());
@@ -151,8 +151,7 @@ public class PromotionService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
- user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
- userRepository.save(user);
+ userRepository.addPromotionCredits(user.getId(), pack.getQuantity());
}
order.setStatus(OrderStatus.PAID);
order.setPaidAt(Instant.now());
@@ -163,15 +162,13 @@ public class PromotionService {
public int promoteWithCredit(String email, CreditPromoteRequest request) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
- if (user.getPromotionCredits() <= 0) {
- throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
- }
- PromotionPlan plan = planRepository.findById(request.planId())
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
+ PromotionPlan plan = requireActivePlan(request.planId());
PropertyListing listing = requireOwnedListing(request.listingId(), email);
- user.setPromotionCredits(user.getPromotionCredits() - 1);
- userRepository.save(user);
+ // Sprawdzenie i zuzycie kredytu w jednym zapytaniu - rownolegle zadania nie wydadza go dwa razy.
+ if (userRepository.consumePromotionCredit(user.getId()) == 0) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
+ }
extendPromotion(listing, plan.getDurationDays());
listingRepository.save(listing);
@@ -188,7 +185,15 @@ public class PromotionService {
order.setPaidAt(Instant.now());
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) {
diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java
index 5823295..2dfc446 100644
--- a/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java
+++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java
@@ -19,20 +19,24 @@ public class ListingReportController {
this.userReportService = userReportService;
}
+ // Zglaszajacy dostaje samo potwierdzenie - pelne zgloszenie (e-mail wlasciciela, dane
+ // zgloszonego) widzi tylko administracja.
@PostMapping("/listings")
- public ListingReportResponse createListingReport(
+ public ReportReceiptResponse createListingReport(
@Valid @RequestBody CreateListingReportRequest request,
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.
@PostMapping("/users")
- public UserReportResponse createUserReport(
+ public ReportReceiptResponse createUserReport(
@Valid @RequestBody CreateUserReportRequest request,
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());
}
}
diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java
index b328d63..c16659f 100644
--- a/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java
+++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
+import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@@ -30,7 +31,10 @@ public class ListingReportService {
@Transactional
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())
+ .filter(found -> found.getStatus() == ListingStatus.APPROVED)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
ListingReport report = new ListingReport();
diff --git a/backend/src/main/java/pl/polskalokalnie/report/ReportReceiptResponse.java b/backend/src/main/java/pl/polskalokalnie/report/ReportReceiptResponse.java
new file mode 100644
index 0000000..d15e1a8
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/report/ReportReceiptResponse.java
@@ -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) {
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java
index a89f0b6..b17cad6 100644
--- a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java
+++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java
@@ -20,7 +20,6 @@ public class UserReportService {
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
private static final int MAX_CONVERSATION_MESSAGES = 50;
- private static final int MAX_MESSAGE_LENGTH = 2000;
private final UserReportRepository userReportRepository;
private final UserRepository userRepository;
@@ -42,7 +41,6 @@ public class UserReportService {
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
report.setListingId(request.listingId());
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
- report.setConversation(cleanConversation(request.conversation()));
report.setReasonId(request.reasonId().trim());
report.setReasonTitle(request.reasonTitle().trim());
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,
// 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);
Conversation conversation = reporterConversation(request.conversationId(), reporter);
AppUser reported;
@@ -115,34 +114,6 @@ public class UserReportService {
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
}
- // Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
- private static List cleanConversation(List payloads) {
- if (payloads == null || payloads.isEmpty()) {
- return List.of();
- }
- List tail = payloads.size() > MAX_CONVERSATION_MESSAGES
- ? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size())
- : payloads;
-
- List 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.
private Conversation reporterConversation(String conversationId, AppUser reporter) {
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
diff --git a/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java
index bf9b796..dda36eb 100644
--- a/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java
+++ b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java
@@ -23,8 +23,11 @@ public class MessageComposer {
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
public String composeEmail(String rawBody, String recipientName, Long leadId) {
- String personalized = personalize(rawBody, recipientName);
- String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized);
+ // O formacie decyduje tresc kampanii, a nie podstawione imie. Imie pochodzi m.in. z publicznego
+ // 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 footer = "
"
+ ""
@@ -64,6 +67,7 @@ public class MessageComposer {
return value
.replace("&", "&")
.replace("<", "<")
- .replace(">", ">");
+ .replace(">", ">")
+ .replace("\"", """);
}
}
diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java
index b110e87..efe6e42 100644
--- a/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java
+++ b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java
@@ -2,6 +2,9 @@ package pl.polskalokalnie.user;
import java.util.Optional;
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 {
@@ -16,4 +19,17 @@ public interface UserRepository extends JpaRepository {
Optional findFirstByRole(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);
}
diff --git a/backend/src/test/java/pl/polskalokalnie/auth/AccountEmailServiceTest.java b/backend/src/test/java/pl/polskalokalnie/auth/AccountEmailServiceTest.java
new file mode 100644
index 0000000..8248d8c
--- /dev/null
+++ b/backend/src/test/java/pl/polskalokalnie/auth/AccountEmailServiceTest.java
@@ -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("Kliknij");
+
+ assertThat(html).doesNotContain("Cześć Jan,
");
+ }
+}
diff --git a/backend/src/test/java/pl/polskalokalnie/auth/JwtAuthFilterTest.java b/backend/src/test/java/pl/polskalokalnie/auth/JwtAuthFilterTest.java
index b6fa6ca..364fa14 100644
--- a/backend/src/test/java/pl/polskalokalnie/auth/JwtAuthFilterTest.java
+++ b/backend/src/test/java/pl/polskalokalnie/auth/JwtAuthFilterTest.java
@@ -1,11 +1,18 @@
package pl.polskalokalnie.auth;
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.util.Optional;
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.Role;
+import pl.polskalokalnie.user.UserRepository;
class JwtAuthFilterTest {
@@ -16,6 +23,60 @@ class JwtAuthFilterTest {
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
void activeUserWithFreshTokenIsAccepted() {
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
diff --git a/backend/src/test/java/pl/polskalokalnie/listing/ListingPrivacyTest.java b/backend/src/test/java/pl/polskalokalnie/listing/ListingPrivacyTest.java
index 18fa9f6..637def7 100644
--- a/backend/src/test/java/pl/polskalokalnie/listing/ListingPrivacyTest.java
+++ b/backend/src/test/java/pl/polskalokalnie/listing/ListingPrivacyTest.java
@@ -14,6 +14,27 @@ class ListingPrivacyTest {
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
void publicListDoesNotExposeOwnerEmail() {
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
diff --git a/backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java b/backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java
index df1970a..e3a58e1 100644
--- a/backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java
+++ b/backend/src/test/java/pl/polskalokalnie/promotion/AutoPayItnServiceTest.java
@@ -54,7 +54,7 @@ class AutoPayItnServiceTest {
order = new PromotionOrder();
order.setExternalId(ORDER_ID);
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) {
@@ -178,7 +178,7 @@ class AutoPayItnServiceTest {
@Test
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"));
diff --git a/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java b/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java
index 566064a..1dcd958 100644
--- a/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java
+++ b/backend/src/test/java/pl/polskalokalnie/promotion/PaymentSecurityTest.java
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
+import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
@@ -26,13 +27,60 @@ class PaymentSecurityTest {
private final PromotionOrderRepository orderRepository = mock(PromotionOrderRepository.class);
private final UserRepository userRepository = mock(UserRepository.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) {
- return new PromotionService(mock(PromotionPlanRepository.class), packageRepository, orderRepository,
- userRepository, mock(ListingRepository.class), paymentConfigService, mock(AutoPayService.class),
+ return new PromotionService(planRepository, packageRepository, orderRepository,
+ userRepository, listingRepository, paymentConfigService, mock(AutoPayService.class),
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
void checkoutWithoutGatewayIsRefusedInsteadOfBeingFree() {
// 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"));
- assertThat(user.getPromotionCredits()).isEqualTo(5);
+ verify(userRepository).addPromotionCredits(user.getId(), 5);
}
@Test
diff --git a/backend/src/test/java/pl/polskalokalnie/send/MessageComposerTest.java b/backend/src/test/java/pl/polskalokalnie/send/MessageComposerTest.java
new file mode 100644
index 0000000..0597980
--- /dev/null
+++ b/backend/src/test/java/pl/polskalokalnie/send/MessageComposerTest.java
@@ -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("Dzień dobry {{name}}!
", "X", 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}}!", "Jan", 1L);
+
+ assertThat(html).doesNotContain("Jan").contains("<b>Jan</b>");
+ }
+}
diff --git a/frontend/src/notifications.tsx b/frontend/src/notifications.tsx
index a72956a..0526012 100644
--- a/frontend/src/notifications.tsx
+++ b/frontend/src/notifications.tsx
@@ -107,7 +107,8 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
.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(() => {
const token = getToken();
if (!user || !token) {
@@ -118,22 +119,52 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
refresh();
- const source = new EventSource(`/api/notifications/stream?access_token=${encodeURIComponent(token)}`);
- source.addEventListener('notification', (event) => {
+ let disposed = false;
+ let source: EventSource | null = null;
+ let reconnectId: number | undefined;
+
+ const connect = async () => {
try {
- const incoming = JSON.parse((event as MessageEvent).data) as ServerNotification;
- setNotifications((current) => (current.some((item) => item.id === incoming.id) ? current : [incoming, ...current]));
+ const { token: ticket } = await apiFetch<{ token: string }>('/notifications/stream-token', { method: 'POST' });
+ 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 {
- /* 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.
const pollId = window.setInterval(refresh, 60000);
return () => {
- source.close();
+ disposed = true;
+ source?.close();
+ window.clearTimeout(reconnectId);
window.clearInterval(pollId);
};
}, [user, refresh]);