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 MapZostaja 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