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]);