Regulamin i polityka prywatnosci zgodne ze stanem serwisu
Deploy Polska Lokalnie / deploy (push) Successful in 22s

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 21:59:15 +02:00
parent a4457df5f8
commit ca5f3ebfc1
19 changed files with 1187 additions and 603 deletions
@@ -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 ---
@@ -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);
@@ -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.
*
* <p>Zostawiamy krotki bufor od wygasniecia: uzytkownik, ktory kliknie stary link, dostaje
* czytelny komunikat zamiast bledu "nie znaleziono".</p>
*/
@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());
}
}
}
@@ -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()
@@ -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
) {
}
@@ -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<String, String> cache = new ConcurrentHashMap<>();
public Map<String, String> translateBatch(PreferredLanguage language, List<String> 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<String, String> passthrough = new LinkedHashMap<>();
texts.forEach((text) -> passthrough.put(text, text));
return passthrough;
}
String targetCode = toGoogleLang(language);
Map<String, String> results = new LinkedHashMap<>();
List<String> 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<String> 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";
};
}
}
@@ -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<String> texts
) {
}
@@ -1,10 +0,0 @@
package pl.polskalokalnie.i18n;
import java.util.Map;
import pl.polskalokalnie.user.PreferredLanguage;
public record TranslateResponse(
PreferredLanguage language,
Map<String, String> translations
) {
}
@@ -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<String> normalizedTexts = request.texts().stream()
.map(text -> text == null ? "" : text)
.distinct()
.collect(Collectors.toList());
Map<String, String> translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts);
return new TranslateResponse(request.language(), translations);
}
}
@@ -67,13 +67,17 @@ public class Lead {
@Column(name = "tag", length = 60)
private List<String> 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)
@@ -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.
*
* <p>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.</p>
*
* <p>Schemat nie ma kluczy obcych z kaskada (ddl-auto=update), wiec kasujemy jawnie,
* tabela po tabeli - dzieki temu widac wprost, co znika.</p>
*/
@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;
}
}
}
@@ -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;
}