- /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) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||
}
|
||||
listingService.deleteAllOfOwner(user.getEmail());
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.lead.LeadSyncService;
|
||||
import pl.polskalokalnie.listing.ListingService;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
@@ -52,6 +53,7 @@ public class AuthService {
|
||||
private final PhoneVerificationService phoneVerificationService;
|
||||
private final NickService nickService;
|
||||
private final UserSettingsService userSettingsService;
|
||||
private final ListingService listingService;
|
||||
|
||||
public AuthService(
|
||||
UserRepository userRepository,
|
||||
@@ -64,7 +66,8 @@ public class AuthService {
|
||||
AccountEmailService accountEmailService,
|
||||
PhoneVerificationService phoneVerificationService,
|
||||
NickService nickService,
|
||||
UserSettingsService userSettingsService
|
||||
UserSettingsService userSettingsService,
|
||||
ListingService listingService
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
@@ -77,6 +80,7 @@ public class AuthService {
|
||||
this.phoneVerificationService = phoneVerificationService;
|
||||
this.nickService = nickService;
|
||||
this.userSettingsService = userSettingsService;
|
||||
this.listingService = listingService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -212,8 +216,12 @@ public class AuthService {
|
||||
*/
|
||||
public void resendPhoneOtp(String email) {
|
||||
String normalized = normalizeEmail(email);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
// Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
|
||||
// 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()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||
}
|
||||
@@ -435,6 +443,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
userSettingsService.deleteForUser(user.getId());
|
||||
listingService.deleteAllOfOwner(user.getEmail());
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,14 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").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.POST, "/api/unsubscribe").permitAll()
|
||||
.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()
|
||||
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
|
||||
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
|
||||
|
||||
@@ -7,7 +7,7 @@ import pl.polskalokalnie.user.AccountType;
|
||||
|
||||
public record RegisterRequest(
|
||||
@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,
|
||||
AccountType accountType,
|
||||
String phone,
|
||||
|
||||
@@ -147,7 +147,15 @@ public class DataSeeder implements CommandLineRunner {
|
||||
String email = adminEmail.trim().toLowerCase();
|
||||
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 (passwordMissing) {
|
||||
log.error("Brak APP_ADMIN_PASSWORD - konto administratora {} NIE zostalo utworzone.", email);
|
||||
return;
|
||||
}
|
||||
AppUser admin = new AppUser();
|
||||
admin.setEmail(email);
|
||||
admin.setFullName(adminName);
|
||||
@@ -164,6 +172,10 @@ public class DataSeeder implements CommandLineRunner {
|
||||
if (!adminResetPassword) {
|
||||
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.
|
||||
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class ListingController {
|
||||
|
||||
private final ListingService listingService;
|
||||
private final ListingViewGuard viewGuard;
|
||||
|
||||
public ListingController(ListingService listingService) {
|
||||
public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
|
||||
this.listingService = listingService;
|
||||
this.viewGuard = viewGuard;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,12 +59,16 @@ public class ListingController {
|
||||
public ListingDetailResponse getById(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "false") boolean incrementView,
|
||||
Authentication authentication
|
||||
Authentication authentication,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
String viewerEmail = authentication == null ? null : authentication.getName();
|
||||
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
|
||||
.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
|
||||
@@ -75,7 +82,7 @@ public class ListingController {
|
||||
.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}")
|
||||
public ListingDetailResponse update(
|
||||
@PathVariable Long id,
|
||||
|
||||
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
|
||||
|
||||
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
|
||||
|
||||
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
|
||||
}
|
||||
|
||||
@@ -342,6 +342,17 @@ public class ListingService {
|
||||
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) {
|
||||
if (value == 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) {
|
||||
PropertyListing listing = listingRepository.findById(listingId)
|
||||
.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
|
||||
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
|
||||
if (seller == null) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingService;
|
||||
import pl.polskalokalnie.message.Conversation;
|
||||
import pl.polskalokalnie.message.ConversationRepository;
|
||||
import pl.polskalokalnie.message.Message;
|
||||
@@ -25,13 +26,16 @@ public class UserReportService {
|
||||
private final UserRepository userRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MessageRepository messageRepository;
|
||||
private final ListingService listingService;
|
||||
|
||||
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
|
||||
ConversationRepository conversationRepository, MessageRepository messageRepository) {
|
||||
ConversationRepository conversationRepository, MessageRepository messageRepository,
|
||||
ListingService listingService) {
|
||||
this.userReportRepository = userReportRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.conversationRepository = conversationRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.listingService = listingService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -102,6 +106,7 @@ public class UserReportService {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||
}
|
||||
|
||||
listingService.deleteAllOfOwner(reported.getEmail());
|
||||
userRepository.delete(reported);
|
||||
|
||||
report.setReportedUserDeleted(true);
|
||||
|
||||
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
/**
|
||||
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
|
||||
@@ -20,17 +22,40 @@ public class UnsubscribeController {
|
||||
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)
|
||||
public String unsubscribe(@RequestParam("token") String token) {
|
||||
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
|
||||
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail";
|
||||
return page("Rezygnacja przyjęta",
|
||||
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")")
|
||||
+ " został wypisany z wiadomości marketingowych (" + kanal + "). "
|
||||
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
|
||||
public String confirm(@RequestParam("token") String token) {
|
||||
UnsubscribeService.Result result = unsubscribeService.preview(token);
|
||||
String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
|
||||
+ "<button type=\"submit\">Wypisz mnie</button></form>";
|
||||
return page("Rezygnacja z wiadomości",
|
||||
"Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
|
||||
}
|
||||
|
||||
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\">"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||
+ "<title>" + title + "</title>"
|
||||
@@ -38,7 +63,10 @@ public class UnsubscribeController {
|
||||
+ "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;"
|
||||
+ "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>"
|
||||
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>";
|
||||
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
|
||||
+ "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);
|
||||
}
|
||||
|
||||
/** 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
|
||||
public Result unsubscribe(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);
|
||||
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"));
|
||||
Target target = parse(token);
|
||||
Lead lead = target.lead();
|
||||
String channel = target.channel();
|
||||
|
||||
if ("sms".equals(channel)) {
|
||||
lead.setSmsOptOut(true);
|
||||
@@ -76,6 +65,38 @@ public class UnsubscribeService {
|
||||
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) {
|
||||
try {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user