- /api/listings/favorites i /mine/** wymagaja logowania (anonimowo byl blad 500). - Nowa rozmowa tylko o opublikowanym ogloszeniu. - Licznik wyswietlen: jedno wyswietlenie na IP i ogloszenie na godzine. - Rezygnacja z mailingu: GET pyta o potwierdzenie, wypisuje dopiero POST (tez z przycisku programu pocztowego); podpis porownywany w stalym czasie. - Usuniecie konta usuwa jego ogloszenia. - Haslo przy rejestracji min. 8 znakow; resend-phone-otp nie zdradza, czy konto istnieje. - docker-compose.prod.yml bez domyslnego Admin123!; bez hasla konto admina nie jest zakladane (blad w logu). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,7 @@ public class AdminController {
|
|||||||
if (user.getRole() == Role.ADMIN) {
|
if (user.getRole() == Role.ADMIN) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||||
}
|
}
|
||||||
|
listingService.deleteAllOfOwner(user.getEmail());
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.lead.LeadSyncService;
|
import pl.polskalokalnie.lead.LeadSyncService;
|
||||||
|
import pl.polskalokalnie.listing.ListingService;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||||
@@ -52,6 +53,7 @@ public class AuthService {
|
|||||||
private final PhoneVerificationService phoneVerificationService;
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
private final NickService nickService;
|
private final NickService nickService;
|
||||||
private final UserSettingsService userSettingsService;
|
private final UserSettingsService userSettingsService;
|
||||||
|
private final ListingService listingService;
|
||||||
|
|
||||||
public AuthService(
|
public AuthService(
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
@@ -64,7 +66,8 @@ public class AuthService {
|
|||||||
AccountEmailService accountEmailService,
|
AccountEmailService accountEmailService,
|
||||||
PhoneVerificationService phoneVerificationService,
|
PhoneVerificationService phoneVerificationService,
|
||||||
NickService nickService,
|
NickService nickService,
|
||||||
UserSettingsService userSettingsService
|
UserSettingsService userSettingsService,
|
||||||
|
ListingService listingService
|
||||||
) {
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.blockedEmailRepository = blockedEmailRepository;
|
this.blockedEmailRepository = blockedEmailRepository;
|
||||||
@@ -77,6 +80,7 @@ public class AuthService {
|
|||||||
this.phoneVerificationService = phoneVerificationService;
|
this.phoneVerificationService = phoneVerificationService;
|
||||||
this.nickService = nickService;
|
this.nickService = nickService;
|
||||||
this.userSettingsService = userSettingsService;
|
this.userSettingsService = userSettingsService;
|
||||||
|
this.listingService = listingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -212,8 +216,12 @@ public class AuthService {
|
|||||||
*/
|
*/
|
||||||
public void resendPhoneOtp(String email) {
|
public void resendPhoneOtp(String email) {
|
||||||
String normalized = normalizeEmail(email);
|
String normalized = normalizeEmail(email);
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
// Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
// zeby nie dalo sie nim sprawdzac, czy dany e-mail ma konto w serwisie.
|
||||||
|
AppUser user = userRepository.findByEmailIgnoreCase(normalized).orElse(null);
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||||
}
|
}
|
||||||
@@ -435,6 +443,7 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userSettingsService.deleteForUser(user.getId());
|
userSettingsService.deleteForUser(user.getId());
|
||||||
|
listingService.deleteAllOfOwner(user.getEmail());
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,14 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||||
|
// GET pokazuje pytanie o potwierdzenie, POST wypisuje (takze z przycisku programu pocztowego).
|
||||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/unsubscribe").permitAll()
|
||||||
.requestMatchers("/api/auth/me").authenticated()
|
.requestMatchers("/api/auth/me").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
// Prywatne listy pod /api/listings - przed publiczna regula GET /api/listings/**,
|
||||||
|
// ktora inaczej przepuszczala je anonimowo (konczylo sie bledem 500).
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/listings/mine", "/api/listings/mine/**",
|
||||||
|
"/api/listings/favorites").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
||||||
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
|
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
|
||||||
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pl.polskalokalnie.user.AccountType;
|
|||||||
|
|
||||||
public record RegisterRequest(
|
public record RegisterRequest(
|
||||||
@NotBlank @Email String email,
|
@NotBlank @Email String email,
|
||||||
@NotBlank @Size(min = 6, max = 72) String password,
|
@NotBlank @Size(min = 8, max = 72) String password,
|
||||||
@NotBlank @Size(max = 120) String fullName,
|
@NotBlank @Size(max = 120) String fullName,
|
||||||
AccountType accountType,
|
AccountType accountType,
|
||||||
String phone,
|
String phone,
|
||||||
|
|||||||
@@ -147,7 +147,15 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
String email = adminEmail.trim().toLowerCase();
|
String email = adminEmail.trim().toLowerCase();
|
||||||
AppUser existing = userRepository.findByEmailIgnoreCase(email).orElse(null);
|
AppUser existing = userRepository.findByEmailIgnoreCase(email).orElse(null);
|
||||||
|
|
||||||
|
// Bez hasla w konfiguracji nie zakladamy ani nie resetujemy konta - na produkcji nie ma wartosci
|
||||||
|
// domyslnej, a jawne haslo z repozytorium (Admin123!) nie moze trafic na serwer przez pomylke.
|
||||||
|
boolean passwordMissing = adminPassword == null || adminPassword.isBlank();
|
||||||
|
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
|
if (passwordMissing) {
|
||||||
|
log.error("Brak APP_ADMIN_PASSWORD - konto administratora {} NIE zostalo utworzone.", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
AppUser admin = new AppUser();
|
AppUser admin = new AppUser();
|
||||||
admin.setEmail(email);
|
admin.setEmail(email);
|
||||||
admin.setFullName(adminName);
|
admin.setFullName(adminName);
|
||||||
@@ -164,6 +172,10 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
if (!adminResetPassword) {
|
if (!adminResetPassword) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (passwordMissing) {
|
||||||
|
log.error("APP_ADMIN_RESET_PASSWORD=true, ale brak APP_ADMIN_PASSWORD - haslo konta {} bez zmian.", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
|
// Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
|
||||||
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
|
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package pl.polskalokalnie.listing;
|
package pl.polskalokalnie.listing;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class ListingController {
|
public class ListingController {
|
||||||
|
|
||||||
private final ListingService listingService;
|
private final ListingService listingService;
|
||||||
|
private final ListingViewGuard viewGuard;
|
||||||
|
|
||||||
public ListingController(ListingService listingService) {
|
public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
|
||||||
this.listingService = listingService;
|
this.listingService = listingService;
|
||||||
|
this.viewGuard = viewGuard;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,12 +59,16 @@ public class ListingController {
|
|||||||
public ListingDetailResponse getById(
|
public ListingDetailResponse getById(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestParam(defaultValue = "false") boolean incrementView,
|
@RequestParam(defaultValue = "false") boolean incrementView,
|
||||||
Authentication authentication
|
Authentication authentication,
|
||||||
|
HttpServletRequest request
|
||||||
) {
|
) {
|
||||||
String viewerEmail = authentication == null ? null : authentication.getName();
|
String viewerEmail = authentication == null ? null : authentication.getName();
|
||||||
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
|
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
|
||||||
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
|
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
|
||||||
return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin);
|
// Adres klienta ustawia nginx (X-Real-IP) - backend nie jest osiagalny z pominieciem nginx.
|
||||||
|
String viewer = request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr();
|
||||||
|
boolean countView = incrementView && viewGuard.shouldCount(id, viewer);
|
||||||
|
return listingService.getById(id, countView, viewerEmail, viewerIsAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -75,7 +82,7 @@ public class ListingController {
|
|||||||
.body(response);
|
.body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; status bez zmian).
|
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; istotna zmiana tresci wraca do moderacji).
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ListingDetailResponse update(
|
public ListingDetailResponse update(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
|
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
|
||||||
|
|
||||||
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
|
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
|
||||||
|
|
||||||
|
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,6 +342,17 @@ public class ListingService {
|
|||||||
listingRepository.deleteById(id);
|
listingRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuniecie wszystkich ogloszen konta - przy usuwaniu konta. Bez tego opublikowane ogloszenia
|
||||||
|
* (z imieniem i telefonem wlasciciela) zostawaly w serwisie bez wlasciciela.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteAllOfOwner(String ownerEmail) {
|
||||||
|
if (ownerEmail != null && !ownerEmail.isBlank()) {
|
||||||
|
listingRepository.deleteAll(listingRepository.findByOwnerEmailIgnoreCase(ownerEmail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String trimToNull(String value) {
|
private static String trimToNull(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package pl.polskalokalnie.listing;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jedno wyswietlenie ogloszenia na adres IP na godzine. Licznik wyswietlen wplywa na ranking
|
||||||
|
* dzielnic i to, co widza sprzedajacy - bez tego dalo sie go dowolnie nabijac odswiezaniem strony.
|
||||||
|
* Pamiec jest ograniczona: po przekroczeniu limitu wpisow stare sa czyszczone.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ListingViewGuard {
|
||||||
|
|
||||||
|
private static final Duration WINDOW = Duration.ofHours(1);
|
||||||
|
private static final int MAX_ENTRIES = 100_000;
|
||||||
|
|
||||||
|
private final Map<String, Instant> lastCounted = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** true, gdy to wyswietlenie ma sie wliczyc do licznika. */
|
||||||
|
public boolean shouldCount(Long listingId, String viewer) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (lastCounted.size() > MAX_ENTRIES) {
|
||||||
|
lastCounted.values().removeIf(at -> at.isBefore(now.minus(WINDOW)));
|
||||||
|
if (lastCounted.size() > MAX_ENTRIES) {
|
||||||
|
lastCounted.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String key = listingId + "|" + (viewer == null ? "" : viewer);
|
||||||
|
Instant previous = lastCounted.get(key);
|
||||||
|
if (previous != null && previous.isAfter(now.minus(WINDOW))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lastCounted.put(key, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,12 @@ public class ConversationService {
|
|||||||
public ConversationDetail start(AppUser me, Long listingId, String content) {
|
public ConversationDetail start(AppUser me, Long listingId, String content) {
|
||||||
PropertyListing listing = listingRepository.findById(listingId)
|
PropertyListing listing = listingRepository.findById(listingId)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
|
||||||
|
// Nowa rozmowa tylko o opublikowanym ogloszeniu - inaczej kolejne numery ujawnialy tytul, cene
|
||||||
|
// i sprzedajacego ogloszen oczekujacych, wstrzymanych i odrzuconych. Trwajaca rozmowa zostaje.
|
||||||
|
if (listing.getStatus() != ListingStatus.APPROVED
|
||||||
|
&& conversationRepository.findByListingIdAndBuyerId(listingId, me.getId()).isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia");
|
||||||
|
}
|
||||||
AppUser seller = listing.getOwnerEmail() == null ? null
|
AppUser seller = listing.getOwnerEmail() == null ? null
|
||||||
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
|
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
|
||||||
if (seller == null) {
|
if (seller == null) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import pl.polskalokalnie.listing.ListingService;
|
||||||
import pl.polskalokalnie.message.Conversation;
|
import pl.polskalokalnie.message.Conversation;
|
||||||
import pl.polskalokalnie.message.ConversationRepository;
|
import pl.polskalokalnie.message.ConversationRepository;
|
||||||
import pl.polskalokalnie.message.Message;
|
import pl.polskalokalnie.message.Message;
|
||||||
@@ -25,13 +26,16 @@ public class UserReportService {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ConversationRepository conversationRepository;
|
private final ConversationRepository conversationRepository;
|
||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
|
private final ListingService listingService;
|
||||||
|
|
||||||
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
|
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
|
||||||
ConversationRepository conversationRepository, MessageRepository messageRepository) {
|
ConversationRepository conversationRepository, MessageRepository messageRepository,
|
||||||
|
ListingService listingService) {
|
||||||
this.userReportRepository = userReportRepository;
|
this.userReportRepository = userReportRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.conversationRepository = conversationRepository;
|
this.conversationRepository = conversationRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
|
this.listingService = listingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -102,6 +106,7 @@ public class UserReportService {
|
|||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listingService.deleteAllOfOwner(reported.getEmail());
|
||||||
userRepository.delete(reported);
|
userRepository.delete(reported);
|
||||||
|
|
||||||
report.setReportedUserDeleted(true);
|
report.setReportedUserDeleted(true);
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
|
|||||||
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
|
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
|
||||||
@@ -20,17 +22,40 @@ public class UnsubscribeController {
|
|||||||
this.unsubscribeService = unsubscribeService;
|
this.unsubscribeService = unsubscribeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Otwarcie linku tylko pyta o potwierdzenie. Skanery poczty (antywirusy, podglad linkow) same
|
||||||
|
* otwieraja linki z maili - gdyby GET od razu wypisywal, ludzie tracili by subskrypcje bez klikniecia.
|
||||||
|
*/
|
||||||
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
|
||||||
public String unsubscribe(@RequestParam("token") String token) {
|
public String confirm(@RequestParam("token") String token) {
|
||||||
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
|
UnsubscribeService.Result result = unsubscribeService.preview(token);
|
||||||
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail";
|
String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
|
||||||
return page("Rezygnacja przyjęta",
|
+ "<button type=\"submit\">Wypisz mnie</button></form>";
|
||||||
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")")
|
return page("Rezygnacja z wiadomości",
|
||||||
+ " został wypisany z wiadomości marketingowych (" + kanal + "). "
|
"Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
|
||||||
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String page(String title, String message) {
|
/**
|
||||||
|
* Wlasciwa rezygnacja. Token w adresie, wiec ten sam POST obsluguje tez przycisk "Wypisz sie"
|
||||||
|
* programow pocztowych (List-Unsubscribe-Post, RFC 8058).
|
||||||
|
*/
|
||||||
|
@PostMapping(produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
public String unsubscribe(@RequestParam("token") String token) {
|
||||||
|
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
|
||||||
|
return page("Rezygnacja przyjęta",
|
||||||
|
"Twój adres " + contact(result) + " został wypisany z wiadomości marketingowych (" + channel(result) + "). "
|
||||||
|
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String contact(UnsubscribeService.Result result) {
|
||||||
|
return result.maskedContact().isEmpty() ? "" : "(" + HtmlUtils.htmlEscape(result.maskedContact()) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String channel(UnsubscribeService.Result result) {
|
||||||
|
return "sms".equals(result.channel()) ? "SMS" : "e-mail";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String page(String title, String message, String action) {
|
||||||
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
|
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
|
||||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||||
+ "<title>" + title + "</title>"
|
+ "<title>" + title + "</title>"
|
||||||
@@ -38,7 +63,10 @@ public class UnsubscribeController {
|
|||||||
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
|
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
|
||||||
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
|
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
|
||||||
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
|
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
|
||||||
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}</style>"
|
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
|
||||||
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>";
|
+ "button{margin-top:20px;padding:12px 22px;border:0;border-radius:10px;background:#12a764;color:#fff;"
|
||||||
|
+ "font-size:15px;font-weight:600;cursor:pointer}</style>"
|
||||||
|
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p>" + action
|
||||||
|
+ "</div></body></html>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,28 +41,17 @@ public class UnsubscribeService {
|
|||||||
return encodedPayload + "." + sign(encodedPayload);
|
return encodedPayload + "." + sign(encodedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sprawdza link bez zmieniania czegokolwiek - do strony z pytaniem o potwierdzenie rezygnacji. */
|
||||||
|
public Result preview(String token) {
|
||||||
|
Target target = parse(token);
|
||||||
|
return new Result(target.channel(), maskContact(target.lead(), target.channel()));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Result unsubscribe(String token) {
|
public Result unsubscribe(String token) {
|
||||||
if (token == null || !token.contains(".")) {
|
Target target = parse(token);
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
Lead lead = target.lead();
|
||||||
}
|
String channel = target.channel();
|
||||||
int dot = token.lastIndexOf('.');
|
|
||||||
String encodedPayload = token.substring(0, dot);
|
|
||||||
String signature = token.substring(dot + 1);
|
|
||||||
if (!sign(encodedPayload).equals(signature)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
|
|
||||||
}
|
|
||||||
|
|
||||||
String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
|
|
||||||
String[] parts = payload.split(":", 2);
|
|
||||||
if (parts.length != 2) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
|
||||||
}
|
|
||||||
Long leadId = parseLeadId(parts[0]);
|
|
||||||
String channel = normalizeChannel(parts[1]);
|
|
||||||
|
|
||||||
Lead lead = leadRepository.findById(leadId)
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
|
|
||||||
|
|
||||||
if ("sms".equals(channel)) {
|
if ("sms".equals(channel)) {
|
||||||
lead.setSmsOptOut(true);
|
lead.setSmsOptOut(true);
|
||||||
@@ -76,6 +65,38 @@ public class UnsubscribeService {
|
|||||||
return new Result(channel, maskContact(lead, channel));
|
return new Result(channel, maskContact(lead, channel));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record Target(Lead lead, String channel) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Target parse(String token) {
|
||||||
|
if (token == null || !token.contains(".")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||||
|
}
|
||||||
|
int dot = token.lastIndexOf('.');
|
||||||
|
String encodedPayload = token.substring(0, dot);
|
||||||
|
String signature = token.substring(dot + 1);
|
||||||
|
// Porownanie w stalym czasie - String.equals konczy sie na pierwszej roznicy i zdradza,
|
||||||
|
// ile poczatkowych znakow podpisu sie zgadza.
|
||||||
|
if (!java.security.MessageDigest.isEqual(sign(encodedPayload).getBytes(StandardCharsets.UTF_8),
|
||||||
|
signature.getBytes(StandardCharsets.UTF_8))) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
try {
|
||||||
|
payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||||
|
}
|
||||||
|
String[] parts = payload.split(":", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||||
|
}
|
||||||
|
Lead lead = leadRepository.findById(parseLeadId(parts[0]))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
|
||||||
|
return new Target(lead, normalizeChannel(parts[1]));
|
||||||
|
}
|
||||||
|
|
||||||
private String sign(String data) {
|
private String sign(String data) {
|
||||||
try {
|
try {
|
||||||
Mac mac = Mac.getInstance(HMAC_ALGO);
|
Mac mac = Mac.getInstance(HMAC_ALGO);
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package pl.polskalokalnie;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import pl.polskalokalnie.lead.Lead;
|
||||||
|
import pl.polskalokalnie.lead.LeadRepository;
|
||||||
|
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||||
|
|
||||||
|
/** Publiczne endpointy - regresje z audytu bezpieczenstwa 2026-09-23. */
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class PublicEndpointsSecurityTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private LeadRepository leadRepository;
|
||||||
|
@Autowired
|
||||||
|
private UnsubscribeService unsubscribeService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privateListingListsRequireLogin() throws Exception {
|
||||||
|
// Regresja: publiczna regula GET /api/listings/** przepuszczala je anonimowo (blad 500).
|
||||||
|
mockMvc.perform(get("/api/listings/favorites")).andExpect(status().isForbidden());
|
||||||
|
mockMvc.perform(get("/api/listings/mine/saves")).andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void phoneCodeResendDoesNotRevealWhetherAccountExists() throws Exception {
|
||||||
|
// Regresja: nieznany adres dawal 404 "Uzytkownik nie istnieje".
|
||||||
|
mockMvc.perform(post("/api/auth/resend-phone-otp")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"nikogo-takiego@example.com\"}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registrationRequiresEightCharacterPassword() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/register")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"nowy@example.com\",\"password\":\"abc123\",\"fullName\":\"Jan Nowak\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openingUnsubscribeLinkOnlyAsksForConfirmation() throws Exception {
|
||||||
|
// Regresja: sam GET (np. skaner linkow w poczcie) wypisywal z mailingu.
|
||||||
|
Lead lead = new Lead();
|
||||||
|
lead.setEmail("subskrybent@example.com");
|
||||||
|
lead.setEmailConsent(true);
|
||||||
|
lead = leadRepository.save(lead);
|
||||||
|
String token = unsubscribeService.generateToken(lead.getId(), "email");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/unsubscribe").param("token", token))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("method=\"post\"")));
|
||||||
|
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isFalse();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/unsubscribe").param("token", token))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("Rezygnacja przyjęta")));
|
||||||
|
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tamperedUnsubscribeTokenIsRejected() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/unsubscribe").param("token", "MTpbZW1haWw.nieprawidlowy-podpis"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pl.polskalokalnie.listing;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class ListingViewGuardTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countsOneViewPerVisitorAndListing() {
|
||||||
|
// Regresja: kazde odswiezenie strony podbijalo licznik wyswietlen.
|
||||||
|
ListingViewGuard guard = new ListingViewGuard();
|
||||||
|
|
||||||
|
assertThat(guard.shouldCount(1L, "203.0.113.1")).isTrue();
|
||||||
|
assertThat(guard.shouldCount(1L, "203.0.113.1")).isFalse();
|
||||||
|
assertThat(guard.shouldCount(1L, "203.0.113.2")).isTrue();
|
||||||
|
assertThat(guard.shouldCount(2L, "203.0.113.1")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
|
import pl.polskalokalnie.user.AppUser;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
|
||||||
|
class ConversationStartTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cannotStartConversationAboutUnpublishedListing() {
|
||||||
|
// Regresja: rozmowa o ogloszeniu oczekujacym/odrzuconym ujawniala jego tytul, cene i sprzedajacego.
|
||||||
|
ConversationRepository conversations = mock(ConversationRepository.class);
|
||||||
|
ListingRepository listings = mock(ListingRepository.class);
|
||||||
|
ConversationService service = new ConversationService(conversations, mock(MessageRepository.class),
|
||||||
|
mock(UserBlockRepository.class), mock(UserRepository.class), listings,
|
||||||
|
mock(TextModerationService.class), mock(ApplicationEventPublisher.class));
|
||||||
|
|
||||||
|
PropertyListing pending = new PropertyListing();
|
||||||
|
pending.setStatus(ListingStatus.PENDING);
|
||||||
|
pending.setOwnerEmail("sprzedajacy@example.com");
|
||||||
|
when(listings.findById(5L)).thenReturn(Optional.of(pending));
|
||||||
|
AppUser me = new AppUser();
|
||||||
|
ReflectionTestUtils.setField(me, "id", 1L);
|
||||||
|
when(conversations.findByListingIdAndBuyerId(5L, 1L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.start(me, 5L, "Dzień dobry"))
|
||||||
|
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||||
|
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
|
||||||
|
verify(conversations, never()).save(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ services:
|
|||||||
# Bez tych zmiennych backend bierze wartosci domyslne z application.yml, wiec login i haslo
|
# Bez tych zmiennych backend bierze wartosci domyslne z application.yml, wiec login i haslo
|
||||||
# administratora ustawione w .env nie mialy zadnego wplywu na dzialajaca aplikacje.
|
# administratora ustawione w .env nie mialy zadnego wplywu na dzialajaca aplikacje.
|
||||||
APP_ADMIN_EMAIL: ${APP_ADMIN_EMAIL:-admin@softspm.com}
|
APP_ADMIN_EMAIL: ${APP_ADMIN_EMAIL:-admin@softspm.com}
|
||||||
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-Admin123!}
|
# Bez wartosci domyslnej: brak hasla w .env.production oznacza, ze konto administratora nie
|
||||||
|
# zostanie zalozone (blad w logu), zamiast powstac z jawnym haslem z repozytorium.
|
||||||
|
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-}
|
||||||
APP_ADMIN_NAME: ${APP_ADMIN_NAME:-Administrator Polska Lokalnie}
|
APP_ADMIN_NAME: ${APP_ADMIN_NAME:-Administrator Polska Lokalnie}
|
||||||
# true = przy starcie ustaw haslo administratora na APP_ADMIN_PASSWORD (odzyskanie dostepu).
|
# true = przy starcie ustaw haslo administratora na APP_ADMIN_PASSWORD (odzyskanie dostepu).
|
||||||
APP_ADMIN_RESET_PASSWORD: ${APP_ADMIN_RESET_PASSWORD:-false}
|
APP_ADMIN_RESET_PASSWORD: ${APP_ADMIN_RESET_PASSWORD:-false}
|
||||||
|
|||||||
@@ -12724,7 +12724,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
minLength={6}
|
minLength={8}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -12746,7 +12746,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
|
|||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
minLength={6}
|
minLength={8}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -12901,7 +12901,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
minLength={6}
|
minLength={8}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -12923,7 +12923,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
|
|||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
minLength={6}
|
minLength={8}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user