Poprawki bezpieczenstwa: maile, kredyty, token strumienia, lokalizacja, spotkania, zgloszenia
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:
2026-09-23 14:36:48 +02:00
parent 0d149467e9
commit b850e4c092
25 changed files with 403 additions and 103 deletions
@@ -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 = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
@@ -80,7 +80,7 @@ public class AccountEmailService {
} else {
String reasonHtml = reason == null || reason.isBlank() ? ""
: "<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ół"
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
+ 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 "<p>Cześć" + who + ",</p>";
}
@@ -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;
}
}
@@ -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)
@@ -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,
@@ -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,
@@ -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;
}
}
@@ -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) {
@@ -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ć.");
@@ -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());
@@ -84,7 +84,7 @@ public class AutoPayItnService {
}
private String process(AutoPayItnParser.ItnTransaction tx) {
Optional<PromotionOrder> found = orderRepository.findByExternalId(tx.orderId());
Optional<PromotionOrder> found = orderRepository.findForUpdateByExternalId(tx.orderId());
if (found.isEmpty()) {
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
return NOT_CONFIRMED;
@@ -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<PromotionOrder, Long> {
Optional<PromotionOrder> findByExternalId(String externalId);
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());
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) {
@@ -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());
}
}
@@ -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();
@@ -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.
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<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.
private Conversation reporterConversation(String conversationId, AppUser reporter) {
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. */
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 = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
@@ -64,6 +67,7 @@ public class MessageComposer {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;");
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -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<AppUser, Long> {
@@ -16,4 +19,17 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> 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);
}
@@ -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("&lt;a/href=");
}
@Test
void greetingUsesFirstName() {
assertThat(AccountEmailService.greeting("Jan Kowalski")).isEqualTo("<p>Cześć Jan,</p>");
}
}
@@ -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();
@@ -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.
@@ -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"));
@@ -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
@@ -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("&lt;a href=&quot;https://evil&quot;&gt;");
}
@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("&lt;b&gt;Jan&lt;/b&gt;");
}
}