Poprawki bezpieczenstwa: maile, kredyty, token strumienia, lokalizacja, spotkania, zgloszenia
Deploy Polska Lokalnie / deploy (push) Successful in 13s
Deploy Polska Lokalnie / deploy (push) Successful in 13s
- Imie, tytul artykulu i powod odrzucenia oraz {{name}} w kampaniach
escapowane w HTML maili; limit dlugosci imienia.
- Kredyty promowania zuzywane i dopisywane warunkowym UPDATE, ITN blokuje
wiersz zamowienia; zakup i kredyt tylko na aktywny plan/pakiet.
- Strumien powiadomien na krotkim (60 s) tokenie ?ticket= pobieranym przez
POST /api/notifications/stream-token; token sesji nie jest przyjmowany z adresu.
- Przyblizona lokalizacja jako srodek kratki 300 m zamiast odwracalnego
przesuniecia liczonego z id.
- Propozycja spotkania respektuje blokade i moderacje, bez e-maila w nazwie.
- Zglaszajacy dostaje samo potwierdzenie; zgloszenia tylko opublikowanych
ogloszen; nie zapisujemy rozmowy przyslanej przez przegladarke.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,7 @@ public class AccountEmailService {
|
||||
boolean approved, String reason, String path) {
|
||||
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<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("<a href="https://evil">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nameInPlainTextCampaignIsEscaped() {
|
||||
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
|
||||
|
||||
String html = composer.composeEmail("Dzień dobry {{name}}!", "<b>Jan</b>", 1L);
|
||||
|
||||
assertThat(html).doesNotContain("<b>Jan</b>").contains("<b>Jan</b>");
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,8 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
.catch(() => { /* cicho - brak sieci/tokenu */ });
|
||||
}, []);
|
||||
|
||||
// Ł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]);
|
||||
|
||||
Reference in New Issue
Block a user