From ca5f3ebfc13417845e2d45c5890a208342f4370b Mon Sep 17 00:00:00 2001 From: Przemek Zatorski Date: Wed, 23 Sep 2026 21:59:15 +0200 Subject: [PATCH] Regulamin i polityka prywatnosci zgodne ze stanem serwisu Audyt pokazal rozbieznosci w obie strony: dokumenty opisywaly funkcje, ktorych nie ma (logowanie Google/Facebook), a kod robil rzeczy, ktorych dokumenty nie ujawnialy. Kod: - zgody marketingowe domyslnie WYLACZONE (byly true dla kazdego nowego konta) + skrypt fix-marketing-consents.sql czyszczacy stare wpisy, - usuniete tlumaczenie przez nieoficjalne API Google - przy zmianie jezyka wysylalo do USA tresci stron, w tym wiadomosci i nazwiska, - osadzone spacery (YouTube, Matterport, Kuula) laduja sie dopiero po zgodzie; YouTube w trybie bez ciasteczek; mapy jak dotad, - usuniecie konta kasuje dane osobowe (AccountErasureService), a nie tylko wiersz app_users, - wygasle tokeny i kody SMS kasowane po 7 dniach (ExpiredTokenCleaner), - zapisujemy moment akceptacji regulaminu przy rejestracji. Dokumenty: opisuja realne funkcje, wszystkich odbiorcow danych (OVH, Autopay, SMTP, SMS, Esri, OpenStreetMap, YouTube/Matterport/Kuula, GUS), transfery poza EOG, konkretne okresy przechowywania, procedure zgloszen z DSA, brak profilowania i tabele pamieci przegladarki zamiast fikcyjnych cookies analitycznych. Zakres NIS2 i DORA opisany zgodnie z prawda. Uklad stron prawnych jak reszta serwisu: kontener 1760 px, karty, spis tresci z boku. Co-Authored-By: Claude Opus 5 (1M context) --- .../polskalokalnie/admin/AdminController.java | 10 +- .../pl/polskalokalnie/auth/AuthService.java | 9 + .../auth/ExpiredTokenCleaner.java | 49 ++ .../polskalokalnie/auth/SecurityConfig.java | 1 - .../auth/dto/RegisterRequest.java | 4 +- .../i18n/RuntimeTranslationService.java | 132 --- .../polskalokalnie/i18n/TranslateRequest.java | 12 - .../i18n/TranslateResponse.java | 10 - .../i18n/TranslationController.java | 31 - .../java/pl/polskalokalnie/lead/Lead.java | 12 +- .../user/AccountErasureService.java | 76 ++ .../java/pl/polskalokalnie/user/AppUser.java | 15 + frontend/src/App.tsx | 238 +----- frontend/src/auth.tsx | 4 +- frontend/src/legal.tsx | 809 ++++++++++++++---- frontend/src/styles.css | 289 ++++++- frontend/tests/routing.spec.ts | 36 +- nginx/default.conf | 6 - scripts/fix-marketing-consents.sql | 47 + 19 files changed, 1187 insertions(+), 603 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/auth/ExpiredTokenCleaner.java delete mode 100644 backend/src/main/java/pl/polskalokalnie/i18n/RuntimeTranslationService.java delete mode 100644 backend/src/main/java/pl/polskalokalnie/i18n/TranslateRequest.java delete mode 100644 backend/src/main/java/pl/polskalokalnie/i18n/TranslateResponse.java delete mode 100644 backend/src/main/java/pl/polskalokalnie/i18n/TranslationController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/AccountErasureService.java create mode 100644 scripts/fix-marketing-consents.sql diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index bf7ec66..639056b 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -38,11 +38,13 @@ import pl.polskalokalnie.lead.AdvertisingEnquiryResponse; import pl.polskalokalnie.lead.AdvertisingEnquiryService; import pl.polskalokalnie.support.SupportMessageResponse; import pl.polskalokalnie.support.SupportMessageService; +import pl.polskalokalnie.user.AccountErasureService; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.BlockedEmail; import pl.polskalokalnie.user.BlockedEmailRepository; import pl.polskalokalnie.user.Role; import pl.polskalokalnie.user.UserRepository; +import pl.polskalokalnie.user.UserSettingsService; @RestController @RequestMapping("/api/admin") @@ -64,6 +66,8 @@ public class AdminController { private final AdminStatsService adminStatsService; private final PasswordEncoder passwordEncoder; private final NotificationService notificationService; + private final AccountErasureService accountErasureService; + private final UserSettingsService userSettingsService; public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, ListingService listingService, ListingReportService listingReportService, @@ -74,7 +78,9 @@ public class AdminController { TextModerationService textModerationService, AdminStatsService adminStatsService, PasswordEncoder passwordEncoder, - NotificationService notificationService) { + NotificationService notificationService, + AccountErasureService accountErasureService, + UserSettingsService userSettingsService) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; this.listingService = listingService; @@ -87,6 +93,8 @@ public class AdminController { this.adminStatsService = adminStatsService; this.passwordEncoder = passwordEncoder; this.notificationService = notificationService; + this.accountErasureService = accountErasureService; + this.userSettingsService = userSettingsService; } // --- Uzytkownicy --- diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 40d8519..776b32d 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -22,6 +22,7 @@ import pl.polskalokalnie.auth.dto.RegisterRequest; import pl.polskalokalnie.auth.dto.RegisterResponse; import pl.polskalokalnie.auth.dto.UpdateProfileRequest; import pl.polskalokalnie.auth.dto.UserResponse; +import pl.polskalokalnie.user.AccountErasureService; import pl.polskalokalnie.user.AccountType; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AuthProvider; @@ -53,6 +54,7 @@ public class AuthService { private final PhoneVerificationService phoneVerificationService; private final NickService nickService; private final UserSettingsService userSettingsService; + private final AccountErasureService accountErasureService; private final ListingService listingService; public AuthService( @@ -67,6 +69,7 @@ public class AuthService { PhoneVerificationService phoneVerificationService, NickService nickService, UserSettingsService userSettingsService, + AccountErasureService accountErasureService, ListingService listingService ) { this.userRepository = userRepository; @@ -80,6 +83,7 @@ public class AuthService { this.phoneVerificationService = phoneVerificationService; this.nickService = nickService; this.userSettingsService = userSettingsService; + this.accountErasureService = accountErasureService; this.listingService = listingService; } @@ -109,6 +113,10 @@ public class AuthService { user.setPhone(phone); user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null); user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + // Rejestracja jest mozliwa tylko po zaznaczeniu zgody w formularzu - zapisujemy jej moment. + if (Boolean.TRUE.equals(request.acceptTerms())) { + user.setTermsAcceptedAt(Instant.now()); + } // Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane. user.setVerified(false); user.setPhoneVerified(false); @@ -442,6 +450,7 @@ public class AuthService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe"); } + accountErasureService.eraseFor(user.getId(), user.getEmail()); userSettingsService.deleteForUser(user.getId()); listingService.deleteAllOfOwner(user.getEmail()); userRepository.delete(user); diff --git a/backend/src/main/java/pl/polskalokalnie/auth/ExpiredTokenCleaner.java b/backend/src/main/java/pl/polskalokalnie/auth/ExpiredTokenCleaner.java new file mode 100644 index 0000000..d025e08 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/ExpiredTokenCleaner.java @@ -0,0 +1,49 @@ +package pl.polskalokalnie.auth; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Sprzatanie jednorazowych tokenow i kodow SMS. Wczesniej zostawaly w bazie bezterminowo, + * mimo ze po wygasnieciu sa bezuzyteczne - a wiersz phone_otps zawiera numer telefonu + * i kod w jawnej postaci. Zasada ograniczenia przechowywania (art. 5 ust. 1 lit. e RODO) + * wymaga, zeby znikaly, gdy przestaja byc potrzebne. + * + *

Zostawiamy krotki bufor od wygasniecia: uzytkownik, ktory kliknie stary link, dostaje + * czytelny komunikat zamiast bledu "nie znaleziono".

+ */ +@Component +public class ExpiredTokenCleaner { + + private static final Logger log = LoggerFactory.getLogger(ExpiredTokenCleaner.class); + + /** Bufor po wygasnieciu - tyle jeszcze trzymamy zuzyty albo przeterminowany wpis. */ + private static final int GRACE_DAYS = 7; + + private final JdbcTemplate jdbcTemplate; + + public ExpiredTokenCleaner(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Scheduled(cron = "0 15 3 * * *") + @Transactional + public void cleanDaily() { + Instant cutoff = Instant.now().minus(GRACE_DAYS, ChronoUnit.DAYS); + try { + int tokens = jdbcTemplate.update("DELETE FROM auth_tokens WHERE expires_at < ?", cutoff); + int otps = jdbcTemplate.update("DELETE FROM phone_otps WHERE expires_at < ?", cutoff); + if (tokens > 0 || otps > 0) { + log.info("Sprzatanie: usunieto {} wygaslych tokenow i {} kodow SMS", tokens, otps); + } + } catch (RuntimeException ex) { + log.warn("Sprzatanie wygaslych tokenow nie powiodlo sie: {}", ex.getMessage()); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index ba87c9f..51fa2ab 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -39,7 +39,6 @@ public class SecurityConfig { .requestMatchers("/api/auth/activate", "/api/auth/resend-activation", "/api/auth/forgot-password", "/api/auth/reset-password", "/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll() - .requestMatchers("/api/i18n/translate").permitAll() .requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll() .requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll() .requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll() diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java index c16c96a..f5411d4 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java @@ -12,6 +12,8 @@ public record RegisterRequest( AccountType accountType, String phone, String nip, - String address + String address, + /** Potwierdzenie akceptacji Regulaminu i Polityki prywatnosci - zapisujemy moment zgody. */ + Boolean acceptTerms ) { } diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/RuntimeTranslationService.java b/backend/src/main/java/pl/polskalokalnie/i18n/RuntimeTranslationService.java deleted file mode 100644 index f6c86b8..0000000 --- a/backend/src/main/java/pl/polskalokalnie/i18n/RuntimeTranslationService.java +++ /dev/null @@ -1,132 +0,0 @@ -package pl.polskalokalnie.i18n; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; -import pl.polskalokalnie.user.PreferredLanguage; - -@Service -public class RuntimeTranslationService { - - private static final int MAX_TEXTS_PER_REQUEST = 180; - private static final int MAX_TEXT_LENGTH = 400; - - private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(6)) - .build(); - private final ObjectMapper objectMapper = new ObjectMapper(); - private final Map cache = new ConcurrentHashMap<>(); - - public Map translateBatch(PreferredLanguage language, List texts) { - if (texts == null || texts.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak tekstów do tłumaczenia"); - } - if (texts.size() > MAX_TEXTS_PER_REQUEST) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Zbyt wiele tekstów do tłumaczenia w jednym żądaniu"); - } - - if (language == PreferredLanguage.PL) { - Map passthrough = new LinkedHashMap<>(); - texts.forEach((text) -> passthrough.put(text, text)); - return passthrough; - } - - String targetCode = toGoogleLang(language); - Map results = new LinkedHashMap<>(); - List toTranslate = new ArrayList<>(); - - for (String text : texts) { - String safeText = text != null ? text : ""; - if (safeText.length() > MAX_TEXT_LENGTH) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Jeden z tekstów jest zbyt długi do tłumaczenia"); - } - - String cacheKey = targetCode + "|" + safeText; - String cached = cache.get(cacheKey); - if (cached != null) { - results.put(safeText, cached); - } else { - toTranslate.add(safeText); - } - } - - for (String text : toTranslate) { - String translated = translateSingle(text, targetCode); - cache.put(targetCode + "|" + text, translated); - results.put(text, translated); - } - - return results; - } - - private String translateSingle(String source, String targetCode) { - if (source.isBlank()) { - return source; - } - - String encoded = URLEncoder.encode(source, StandardCharsets.UTF_8); - String url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=pl&tl=" - + targetCode + "&dt=t&q=" + encoded; - - HttpRequest request = HttpRequest.newBuilder(URI.create(url)) - .GET() - .timeout(Duration.ofSeconds(10)) - .header("Accept", "application/json") - .header("User-Agent", "PolskaLokalnie/1.0") - .build(); - - try { - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Usługa tłumaczeń jest chwilowo niedostępna"); - } - return parseTranslatedText(response.body(), source); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex); - } catch (IOException ex) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex); - } - } - - private String parseTranslatedText(String payload, String fallback) throws IOException { - JsonNode root = objectMapper.readTree(payload); - JsonNode topSegments = root.path(0); - if (!topSegments.isArray() || topSegments.isEmpty()) { - return fallback; - } - StringBuilder translated = new StringBuilder(); - for (JsonNode segment : topSegments) { - JsonNode part = segment.path(0); - if (part.isTextual()) { - translated.append(part.asText()); - } - } - String value = translated.toString(); - return value.isBlank() ? fallback : value; - } - - private String toGoogleLang(PreferredLanguage language) { - return switch (language) { - case EN -> "en"; - case UK -> "uk"; - case DE -> "de"; - case PL -> "pl"; - }; - } -} diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/TranslateRequest.java b/backend/src/main/java/pl/polskalokalnie/i18n/TranslateRequest.java deleted file mode 100644 index c1a4f56..0000000 --- a/backend/src/main/java/pl/polskalokalnie/i18n/TranslateRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package pl.polskalokalnie.i18n; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import java.util.List; -import pl.polskalokalnie.user.PreferredLanguage; - -public record TranslateRequest( - @NotNull PreferredLanguage language, - @NotEmpty List texts -) { -} diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/TranslateResponse.java b/backend/src/main/java/pl/polskalokalnie/i18n/TranslateResponse.java deleted file mode 100644 index f553d78..0000000 --- a/backend/src/main/java/pl/polskalokalnie/i18n/TranslateResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package pl.polskalokalnie.i18n; - -import java.util.Map; -import pl.polskalokalnie.user.PreferredLanguage; - -public record TranslateResponse( - PreferredLanguage language, - Map translations -) { -} diff --git a/backend/src/main/java/pl/polskalokalnie/i18n/TranslationController.java b/backend/src/main/java/pl/polskalokalnie/i18n/TranslationController.java deleted file mode 100644 index 75544af..0000000 --- a/backend/src/main/java/pl/polskalokalnie/i18n/TranslationController.java +++ /dev/null @@ -1,31 +0,0 @@ -package pl.polskalokalnie.i18n; - -import jakarta.validation.Valid; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/i18n") -public class TranslationController { - - private final RuntimeTranslationService runtimeTranslationService; - - public TranslationController(RuntimeTranslationService runtimeTranslationService) { - this.runtimeTranslationService = runtimeTranslationService; - } - - @PostMapping("/translate") - public TranslateResponse translate(@Valid @RequestBody TranslateRequest request) { - List normalizedTexts = request.texts().stream() - .map(text -> text == null ? "" : text) - .distinct() - .collect(Collectors.toList()); - Map translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts); - return new TranslateResponse(request.language(), translations); - } -} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/Lead.java b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java index 8f9ce06..33502c3 100644 --- a/backend/src/main/java/pl/polskalokalnie/lead/Lead.java +++ b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java @@ -67,13 +67,17 @@ public class Lead { @Column(name = "tag", length = 60) private List tags = new ArrayList<>(); + // Zgoda marketingowa musi byc wyrazna i uprzednia (art. 4 pkt 11 i art. 7 RODO oraz Prawo + // komunikacji elektronicznej), wiec domyslnie jej NIE MA. Wlacza ja sam uzytkownik + // w ustawieniach konta (MarketingConsentController) albo administrator przy imporcie leada, + // majac podstawe zebrana poza serwisem. @Column(nullable = false) - @ColumnDefault("true") - private boolean emailConsent = true; + @ColumnDefault("false") + private boolean emailConsent = false; @Column(nullable = false) - @ColumnDefault("true") - private boolean smsConsent = true; + @ColumnDefault("false") + private boolean smsConsent = false; // Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje. @Column(nullable = false) diff --git a/backend/src/main/java/pl/polskalokalnie/user/AccountErasureService.java b/backend/src/main/java/pl/polskalokalnie/user/AccountErasureService.java new file mode 100644 index 0000000..ecb17f6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/AccountErasureService.java @@ -0,0 +1,76 @@ +package pl.polskalokalnie.user; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Usuniecie danych osobowych konta (art. 17 RODO). Dotad kasowany byl tylko wiersz app_users, + * ustawienia i ogloszenia - reszta zostawala w bazie jako sieroty: wiadomosci, powiadomienia, + * alerty, spotkania, tokeny, kody SMS z numerem telefonu i lead marketingowy. + * + *

Zostaja wylacznie dane, ktorych usuniecie byloby sprzeczne z innym obowiazkiem: + * zamowienia promowania (dokumentacja rozliczeniowa), zgloszenia ogloszen i uzytkownikow + * oraz korespondencja z obsluga (obrona przed roszczeniami i obowiazki moderacyjne). + * Te kategorie maja wlasne okresy przechowywania opisane w Polityce prywatnosci.

+ * + *

Schemat nie ma kluczy obcych z kaskada (ddl-auto=update), wiec kasujemy jawnie, + * tabela po tabeli - dzieki temu widac wprost, co znika.

+ */ +@Service +public class AccountErasureService { + + private static final Logger log = LoggerFactory.getLogger(AccountErasureService.class); + + private final JdbcTemplate jdbcTemplate; + + public AccountErasureService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public void eraseFor(Long userId, String email) { + if (userId == null || email == null || email.isBlank()) { + return; + } + int removed = 0; + + // Dane powiazane z identyfikatorem konta. + removed += update("DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?", userId, userId); + removed += update("DELETE FROM conversations WHERE buyer_id = ? OR seller_id = ?", userId, userId); + removed += update("DELETE FROM user_blocks WHERE blocker_id = ? OR blocked_id = ?", userId, userId); + removed += update("DELETE FROM meetings WHERE proposer_id = ? OR counterpart_id = ?", userId, userId); + + // Dane powiazane z adresem e-mail. + removed += update("DELETE FROM listing_favorites WHERE lower(user_email) = lower(?)", email); + removed += update("DELETE FROM saved_searches WHERE lower(user_email) = lower(?)", email); + removed += update("DELETE FROM price_alert_events WHERE lower(user_email) = lower(?)", email); + removed += update("DELETE FROM price_alerts WHERE lower(user_email) = lower(?)", email); + removed += update("DELETE FROM notifications WHERE lower(user_email) = lower(?)", email); + removed += update("DELETE FROM auth_tokens WHERE lower(user_email) = lower(?)", email); + removed += update("DELETE FROM phone_otps WHERE lower(user_email) = lower(?)", email); + + // Baza marketingowa: lead, jego tagi i pozycje w kolejkach kampanii. + removed += update("DELETE FROM campaign_recipients WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email); + removed += update("DELETE FROM lead_tags WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email); + removed += update("DELETE FROM leads WHERE lower(email) = lower(?)", email); + + // Poradniki uzytkownika znikaja razem z kontem - tak samo jak jego ogloszenia. + removed += update("DELETE FROM article_attachments WHERE article_id IN (SELECT id FROM articles WHERE lower(author_email) = lower(?))", email); + removed += update("DELETE FROM articles WHERE lower(author_email) = lower(?)", email); + + log.info("Usunieto {} rekordow powiazanych z kontem id={}", removed, userId); + } + + private int update(String sql, Object... args) { + try { + return jdbcTemplate.update(sql, args); + } catch (RuntimeException ex) { + // Brak tabeli w starszej bazie nie moze zablokowac usuniecia konta. + log.warn("Nie udalo sie wykonac czyszczenia: {} ({})", sql, ex.getMessage()); + return 0; + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java index 752f48f..4b68d88 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -103,6 +103,13 @@ public class AppUser { // Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano. private Instant lastActiveAt; + /** + * Moment akceptacji Regulaminu i Polityki prywatnosci przy rejestracji. Zapisujemy go, + * zeby mozna bylo wykazac, kiedy uzytkownik zaakceptowal dokumenty (art. 7 ust. 1 RODO). + */ + @Column(name = "terms_accepted_at") + private Instant termsAcceptedAt; + @PrePersist void onCreate() { if (createdAt == null) { @@ -274,6 +281,14 @@ public class AppUser { this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); } + public Instant getTermsAcceptedAt() { + return termsAcceptedAt; + } + + public void setTermsAcceptedAt(Instant termsAcceptedAt) { + this.termsAcceptedAt = termsAcceptedAt; + } + public Instant getLastActiveAt() { return lastActiveAt; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4f1220..3beecd5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,7 +19,7 @@ import { BannerSlot } from './Banners'; import { AdminBannersView } from './admin/banners'; import { AdminPromotionView } from './admin/promotion'; import { AdminSettingsView } from './admin/settings'; -import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal'; +import { TermsPage, PrivacyPolicyPage, CookieBanner, hasExternalEmbedConsent } from './legal'; import { SiteFooter } from './Footer'; import { ActivationPage, PasswordResetPage, PaymentRedirectPage, PhoneOtpModal } from './AuthPages'; @@ -1995,7 +1995,8 @@ function getVirtualTourEmbed(url: string): VirtualTourEmbedInfo | null { const id = pathParts[0]; if (id) { return { - embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(id)}`, + // Tryb bez ciasteczek: YouTube nie zaklada ciasteczek sledzacych przed odtworzeniem. + embedUrl: `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`, providerLabel: 'YouTube', }; } @@ -2007,7 +2008,7 @@ function getVirtualTourEmbed(url: string): VirtualTourEmbedInfo | null { || (pathParts[0] === 'shorts' ? pathParts[1] : null); if (videoId) { return { - embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(videoId)}`, + embedUrl: `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`, providerLabel: 'YouTube', }; } @@ -2636,11 +2637,6 @@ function App() { [favoriteListingIdList, listingPriceAlertIdList], ); const monitoredListingIdsSignature = useMemo(() => monitoredListingIdList.join(','), [monitoredListingIdList]); - const translatedOriginalTextRef = useRef>(new Map()); - const translatedOriginalAttrRef = useRef>>(new Map()); - const translationObserverRef = useRef(null); - const translationTimerRef = useRef(null); - const translationInFlightRef = useRef(false); const filters = useFilterState(); const notificationsCenter = useNotifications(); const [savedSearchesState, setSavedSearchesState] = useState([]); @@ -2805,198 +2801,6 @@ function App() { document.documentElement.lang = htmlLang; }, [user?.preferredLanguage]); - useEffect(() => { - const targetLanguage = user?.preferredLanguage ?? 'PL'; - const textNodeSources = translatedOriginalTextRef.current; - const attrSources = translatedOriginalAttrRef.current; - - const restorePolish = () => { - textNodeSources.forEach((source, node) => { - if (node.isConnected) { - node.textContent = source; - } - }); - attrSources.forEach((sourceAttrs, element) => { - if (!element.isConnected) { - return; - } - Object.entries(sourceAttrs).forEach(([attr, value]) => { - element.setAttribute(attr, value); - }); - }); - }; - - const shouldTranslateSource = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - // Skip pure numbers and separators. - if (!/[a-zA-Z\u00C0-\u024F\u0400-\u04FF\u0104\u0105\u0106\u0107\u0118\u0119\u0141\u0142\u0143\u0144\u00D3\u00F3\u015A\u015B\u0179\u017A\u017B\u017C]/.test(trimmed)) { - return false; - } - return true; - }; - - const collectTargets = () => { - const textTargets: Array<{ node: Text; source: string }> = []; - const attrTargets: Array<{ element: Element; attr: string; source: string }> = []; - - const root = document.querySelector('.site-shell') ?? document.body; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let current = walker.nextNode(); - while (current) { - const textNode = current as Text; - const parent = textNode.parentElement; - if (parent && !['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA'].includes(parent.tagName)) { - const source = textNodeSources.get(textNode) ?? textNode.textContent ?? ''; - if (!textNodeSources.has(textNode)) { - textNodeSources.set(textNode, source); - } - if (shouldTranslateSource(source)) { - textTargets.push({ node: textNode, source }); - } - } - current = walker.nextNode(); - } - - root.querySelectorAll('[placeholder], [title], [aria-label]').forEach((element) => { - const tracked = attrSources.get(element) ?? {}; - ['placeholder', 'title', 'aria-label'].forEach((attr) => { - const raw = element.getAttribute(attr); - if (!raw) { - return; - } - const source = tracked[attr] ?? raw; - if (!tracked[attr]) { - tracked[attr] = source; - } - if (shouldTranslateSource(source)) { - attrTargets.push({ element, attr, source }); - } - }); - if (Object.keys(tracked).length > 0) { - attrSources.set(element, tracked); - } - }); - - return { textTargets, attrTargets }; - }; - - const translateVisibleContent = async () => { - if (targetLanguage === 'PL') { - restorePolish(); - return; - } - if (translationInFlightRef.current) { - return; - } - - const { textTargets, attrTargets } = collectTargets(); - const sources = Array.from(new Set([ - ...textTargets.map((item) => item.source), - ...attrTargets.map((item) => item.source), - ])); - - if (sources.length === 0) { - return; - } - - translationInFlightRef.current = true; - try { - const mergedTranslations: Record = {}; - const missingForApi: string[] = []; - - sources.forEach((source) => { - const manual = getManualTranslation(targetLanguage, source); - if (manual != null) { - mergedTranslations[source] = manual; - } else if (source.length <= 380) { - missingForApi.push(source); - } else { - mergedTranslations[source] = source; - } - }); - - if (missingForApi.length > 0) { - const fallbackChunks: string[][] = []; - for (let index = 0; index < missingForApi.length; index += 150) { - fallbackChunks.push(missingForApi.slice(index, index + 150)); - } - - for (const chunk of fallbackChunks) { - const response = await apiFetch<{ language: PreferredLanguage; translations: Record }>('/i18n/translate', { - method: 'POST', - body: JSON.stringify({ language: targetLanguage, texts: chunk }), - }); - Object.assign(mergedTranslations, response.translations); - } - } - - textTargets.forEach(({ node, source }) => { - if (!node.isConnected) { - return; - } - const translated = mergedTranslations[source]; - if (translated && node.textContent !== translated) { - node.textContent = translated; - } - }); - - attrTargets.forEach(({ element, attr, source }) => { - if (!element.isConnected) { - return; - } - const translated = mergedTranslations[source]; - if (translated) { - element.setAttribute(attr, translated); - } - }); - } catch { - // If translation fails, leave original text untouched. - } finally { - translationInFlightRef.current = false; - } - }; - - const queueTranslate = () => { - if (translationTimerRef.current != null) { - window.clearTimeout(translationTimerRef.current); - } - translationTimerRef.current = window.setTimeout(() => { - void translateVisibleContent(); - }, 80); - }; - - translationObserverRef.current?.disconnect(); - if (targetLanguage === 'PL') { - restorePolish(); - return; - } - - queueTranslate(); - const observer = new MutationObserver(() => { - queueTranslate(); - }); - observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true, - attributes: true, - attributeFilter: ['placeholder', 'title', 'aria-label'], - }); - translationObserverRef.current = observer; - - return () => { - observer.disconnect(); - translationObserverRef.current = null; - if (translationTimerRef.current != null) { - window.clearTimeout(translationTimerRef.current); - translationTimerRef.current = null; - } - }; - }, [user?.preferredLanguage, location.pathname]); - // Miasto i typ oferty trafiaja do query stringa - wynik na mapie da sie udostepnic linkiem. const openMap = (name: string, source: 'buy' | 'rent' = 'buy') => { navigate(mapPath(name, source === 'rent' ? 'RENT' : 'SALE')); @@ -26777,6 +26581,9 @@ function ListingDetailPage({ }) { const navigate = useNavigate(); const { user, loading: authLoading } = useAuth(); + // Osadzony spacer z obcego serwisu ladujemy dopiero za zgoda ogladajacego (baner cookie + // "akceptuje wszystkie" ja obejmuje) - ramka przekazuje dostawcy adres IP. + const [tourEmbedAllowed, setTourEmbedAllowed] = useState(() => hasExternalEmbedConsent()); const { formatPrice, formatPricePerArea, formatArea, areaSuffix } = useDisplay(); const [listing, setListing] = useState(null); // Historia ogladania jest per konto, wiec zapisujemy ja dopiero, gdy wiadomo, kto oglada - @@ -27390,15 +27197,28 @@ function ListingDetailPage({

Wirtualny spacer 3D

{virtualTourEmbed ? ( -
-