This commit is contained in:
@@ -145,6 +145,8 @@ public class AuthService {
|
||||
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
|
||||
}
|
||||
|
||||
user.setLastActiveAt(Instant.now());
|
||||
userRepository.save(user);
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
@@ -298,10 +300,20 @@ public class AuthService {
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
// Odstep, po ktorym wejscie do aplikacji (odswiezenie /auth/me) na nowo odnotowuje aktywnosc.
|
||||
// Bez tego zapisywalibysmy do bazy przy kazdym zadaniu profilu - a wystarczy zgrubna dokladnosc.
|
||||
private static final Duration LAST_ACTIVE_REFRESH = Duration.ofMinutes(2);
|
||||
|
||||
@Transactional
|
||||
public UserResponse currentUser(String email) {
|
||||
return userRepository.findByEmailIgnoreCase(email)
|
||||
.map(this::withAvatar)
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
Instant now = Instant.now();
|
||||
if (user.getLastActiveAt() == null || user.getLastActiveAt().isBefore(now.minus(LAST_ACTIVE_REFRESH))) {
|
||||
user.setLastActiveAt(now);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return withAvatar(user);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,8 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/auth/me").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").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()
|
||||
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
||||
|
||||
@@ -56,6 +56,10 @@ public record ListingDetailResponse(
|
||||
String ownerAccountType,
|
||||
// Nazwa wlasciciela do etykiety oferty firmowej (dla konta firmowego). Dla oferty prywatnej pomijana.
|
||||
String ownerName,
|
||||
// Data zalozenia konta wlasciciela - na karcie sprzedajacego pokazujemy "Na Polska Lokalnie od...".
|
||||
Instant ownerCreatedAt,
|
||||
// Ostatnia aktywnosc wlasciciela - na karcie sprzedajacego "Ostatnio aktywny/a...". Null = brak konta.
|
||||
Instant ownerLastActiveAt,
|
||||
ListingStatus status,
|
||||
Instant createdAt,
|
||||
Long viewsCount,
|
||||
@@ -68,7 +72,7 @@ public record ListingDetailResponse(
|
||||
private static final double METERS_PER_DEGREE = 111_320d;
|
||||
|
||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
|
||||
return from(listing, ownerId, ownerAvatar, null, null, false);
|
||||
return from(listing, ownerId, ownerAvatar, null, null, null, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +82,8 @@ public record ListingDetailResponse(
|
||||
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
|
||||
*/
|
||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
|
||||
String ownerAccountType, String ownerName, boolean masked) {
|
||||
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
|
||||
boolean masked) {
|
||||
String street = masked ? null : listing.getStreet();
|
||||
String building = masked ? null : listing.getBuilding();
|
||||
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
||||
@@ -126,6 +131,8 @@ public record ListingDetailResponse(
|
||||
ownerAvatar,
|
||||
ownerAccountType,
|
||||
ownerName,
|
||||
ownerCreatedAt,
|
||||
ownerLastActiveAt,
|
||||
listing.getStatus(),
|
||||
listing.getCreatedAt(),
|
||||
listing.getViewsCount(),
|
||||
|
||||
@@ -62,11 +62,11 @@ public class ListingService {
|
||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
|
||||
AppUser owner = ownerOf(listing);
|
||||
if (owner == null) {
|
||||
return ListingDetailResponse.from(listing, null, null, null, null, masked);
|
||||
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked);
|
||||
}
|
||||
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
|
||||
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
|
||||
ownerAccountType, owner.getFullName(), masked);
|
||||
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package pl.polskalokalnie.og;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.OfferType;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
|
||||
/**
|
||||
* Serwerowe renderowanie strony ogloszenia dla crawlerow social media (Facebook, WhatsApp,
|
||||
* Messenger, Twitter/X...). Te boty nie wykonuja JavaScriptu, wiec meta tagi ustawiane dynamicznie
|
||||
* w Reakcie sa dla nich niewidoczne - podglad linku bylby pusty. Nginx wykrywa crawlera po
|
||||
* User-Agent i dla adresu /oferta/{id} kieruje go tutaj zamiast do aplikacji SPA; zwykly uzytkownik
|
||||
* dostaje niezmieniona aplikacje. Zwracany HTML zawiera Open Graph/Twitter Card z tytulem, cena,
|
||||
* metrazem, lokalizacja i glownym zdjeciem, a nastepnie przekierowuje do kanonicznego adresu SPA.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/og")
|
||||
public class OgController {
|
||||
|
||||
private final ListingRepository listingRepository;
|
||||
private final String baseUrl;
|
||||
|
||||
public OgController(ListingRepository listingRepository,
|
||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||
this.listingRepository = listingRepository;
|
||||
// Bez ukosnika na koncu - laczymy przez konkatenacje ze sciezka zaczynajaca sie od "/".
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@GetMapping(value = "/listing/{id}", produces = MediaType.TEXT_HTML_VALUE + ";charset=UTF-8")
|
||||
public ResponseEntity<String> listingPreview(@PathVariable Long id) {
|
||||
String canonicalUrl = baseUrl + "/oferta/" + id;
|
||||
PropertyListing listing = listingRepository.findById(id).orElse(null);
|
||||
|
||||
// Nieopublikowane ogloszenia (oczekujace, wstrzymane, odrzucone) i nieistniejace nie wyciekaja
|
||||
// danych - crawler dostaje ogolna wizytowke serwisu z przekierowaniem do aplikacji.
|
||||
if (listing == null || listing.getStatus() != ListingStatus.APPROVED) {
|
||||
String html = renderHtml(
|
||||
"Polska Lokalnie",
|
||||
"Ogloszenia sprzedazy i wynajmu mieszkan, domow oraz apartamentow.",
|
||||
null,
|
||||
null,
|
||||
canonicalUrl);
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache())
|
||||
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
|
||||
.body(html);
|
||||
}
|
||||
|
||||
String location = formatLocation(listing);
|
||||
// Opis podgladu = cena · metraz · lokalizacja - dokladnie to, co ma zobaczyc odbiorca linku.
|
||||
String description = String.join(" · ", nonBlank(
|
||||
formatPrice(listing),
|
||||
formatArea(listing.getArea()),
|
||||
location));
|
||||
|
||||
String imageUrl = hasImage(listing) ? baseUrl + "/api/og/listing/" + id + "/image" : null;
|
||||
|
||||
String html = renderHtml(listing.getTitle(), description, imageUrl, listing.getTitle(), canonicalUrl);
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache())
|
||||
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
|
||||
.body(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Glowne zdjecie ogloszenia jako realny obraz pod stabilnym adresem. Zdjecia trzymamy jako
|
||||
* data URL (base64) - crawler nie potrafi ich uzyc jako og:image, wiec dekodujemy je tutaj do
|
||||
* bajtow z prawidlowym Content-Type.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
@GetMapping("/listing/{id}/image")
|
||||
public ResponseEntity<byte[]> listingImage(@PathVariable Long id) {
|
||||
PropertyListing listing = listingRepository.findById(id).orElse(null);
|
||||
if (listing == null || listing.getStatus() != ListingStatus.APPROVED || !hasImage(listing)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String cover = listing.getPhotos().get(0);
|
||||
DecodedImage image = decodeDataUrl(cover);
|
||||
if (image == null) {
|
||||
// Zdjecie zapisane jako zwykly URL (nie data URL) - odsylamy crawlera pod ten adres.
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", cover)
|
||||
.build();
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.valueOf(image.mimeType()))
|
||||
.cacheControl(CacheControl.maxAge(java.time.Duration.ofHours(1)).cachePublic())
|
||||
.body(image.data());
|
||||
}
|
||||
|
||||
private static boolean hasImage(PropertyListing listing) {
|
||||
List<String> photos = listing.getPhotos();
|
||||
return photos != null && !photos.isEmpty() && photos.get(0) != null && !photos.get(0).isBlank();
|
||||
}
|
||||
|
||||
private record DecodedImage(String mimeType, byte[] data) {}
|
||||
|
||||
private static DecodedImage decodeDataUrl(String value) {
|
||||
if (value == null || !value.startsWith("data:")) {
|
||||
return null;
|
||||
}
|
||||
int comma = value.indexOf(',');
|
||||
if (comma < 0) {
|
||||
return null;
|
||||
}
|
||||
String meta = value.substring(5, comma); // np. "image/jpeg;base64"
|
||||
if (!meta.contains(";base64")) {
|
||||
return null;
|
||||
}
|
||||
String mime = meta.substring(0, meta.indexOf(';'));
|
||||
if (mime.isBlank()) {
|
||||
mime = MediaType.IMAGE_JPEG_VALUE;
|
||||
}
|
||||
try {
|
||||
byte[] data = Base64.getDecoder().decode(value.substring(comma + 1));
|
||||
return new DecodedImage(mime, data);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Formatowanie zgodne z prezentacja na froncie (formatPln, "X m²", "district, city") ---
|
||||
|
||||
private static String formatPrice(PropertyListing listing) {
|
||||
BigDecimal price = listing.getPrice();
|
||||
if (price == null) {
|
||||
return "";
|
||||
}
|
||||
NumberFormat format = NumberFormat.getIntegerInstance(new Locale("pl", "PL"));
|
||||
String amount = format.format(price.setScale(0, RoundingMode.HALF_UP)).replace(' ', ' ');
|
||||
String base = amount + " zł";
|
||||
return listing.getOfferType() == OfferType.RENT ? base + " / mies." : base;
|
||||
}
|
||||
|
||||
private static String formatArea(Double area) {
|
||||
if (area == null || area <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (area == Math.floor(area)) {
|
||||
return (long) area.doubleValue() + " m²";
|
||||
}
|
||||
return String.format(new Locale("pl", "PL"), "%.1f m²", area);
|
||||
}
|
||||
|
||||
private static String formatLocation(PropertyListing listing) {
|
||||
String city = listing.getCity() == null ? "" : listing.getCity().trim();
|
||||
String district = listing.getDistrict() == null ? "" : listing.getDistrict().trim();
|
||||
if (!district.isEmpty() && !city.isEmpty()) {
|
||||
return district + ", " + city;
|
||||
}
|
||||
return city.isEmpty() ? district : city;
|
||||
}
|
||||
|
||||
private static String[] nonBlank(String... values) {
|
||||
return java.util.Arrays.stream(values).filter(v -> v != null && !v.isBlank()).toArray(String[]::new);
|
||||
}
|
||||
|
||||
private String renderHtml(String title, String description, String imageUrl, String imageAlt, String canonicalUrl) {
|
||||
String t = escapeHtml(title);
|
||||
String d = escapeHtml(description);
|
||||
StringBuilder head = new StringBuilder();
|
||||
head.append("<meta property=\"og:type\" content=\"website\"/>\n");
|
||||
head.append("<meta property=\"og:site_name\" content=\"Polska Lokalnie\"/>\n");
|
||||
head.append("<meta property=\"og:locale\" content=\"pl_PL\"/>\n");
|
||||
head.append("<meta property=\"og:title\" content=\"").append(t).append("\"/>\n");
|
||||
head.append("<meta property=\"og:description\" content=\"").append(d).append("\"/>\n");
|
||||
head.append("<meta property=\"og:url\" content=\"").append(escapeHtml(canonicalUrl)).append("\"/>\n");
|
||||
head.append("<meta name=\"twitter:card\" content=\"").append(imageUrl != null ? "summary_large_image" : "summary").append("\"/>\n");
|
||||
head.append("<meta name=\"twitter:title\" content=\"").append(t).append("\"/>\n");
|
||||
head.append("<meta name=\"twitter:description\" content=\"").append(d).append("\"/>\n");
|
||||
if (imageUrl != null) {
|
||||
String img = escapeHtml(imageUrl);
|
||||
head.append("<meta property=\"og:image\" content=\"").append(img).append("\"/>\n");
|
||||
head.append("<meta property=\"og:image:alt\" content=\"").append(escapeHtml(imageAlt)).append("\"/>\n");
|
||||
head.append("<meta name=\"twitter:image\" content=\"").append(img).append("\"/>\n");
|
||||
}
|
||||
return "<!doctype html>\n"
|
||||
+ "<html lang=\"pl\">\n<head>\n"
|
||||
+ "<meta charset=\"utf-8\"/>\n"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
|
||||
+ "<title>" + t + "</title>\n"
|
||||
+ "<meta name=\"description\" content=\"" + d + "\"/>\n"
|
||||
+ "<link rel=\"canonical\" href=\"" + escapeHtml(canonicalUrl) + "\"/>\n"
|
||||
+ head
|
||||
// Czlowiek, ktory trafi tu z linku (a nie bot), zostaje od razu przeniesiony do aplikacji.
|
||||
+ "<meta http-equiv=\"refresh\" content=\"0; url=" + escapeHtml(canonicalUrl) + "\"/>\n"
|
||||
+ "</head>\n<body>\n"
|
||||
+ "<p><a href=\"" + escapeHtml(canonicalUrl) + "\">" + t + "</a></p>\n"
|
||||
+ "<script>window.location.replace(" + jsString(canonicalUrl) + ");</script>\n"
|
||||
+ "</body>\n</html>\n";
|
||||
}
|
||||
|
||||
private static String escapeHtml(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
// Bezpieczne osadzenie adresu w literale JS (redirect) - adres pochodzi z konfiguracji, ale
|
||||
// trzymamy sie zasady, ze nic nie trafia do skryptu bez ucieczki.
|
||||
private static String jsString(String value) {
|
||||
String escaped = value == null ? "" : value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("<", "\\u003C")
|
||||
.replace(">", "\\u003E");
|
||||
return "'" + escaped + "'";
|
||||
}
|
||||
}
|
||||
@@ -95,11 +95,18 @@ public class AppUser {
|
||||
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
|
||||
private Instant passwordChangedAt;
|
||||
|
||||
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
|
||||
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
|
||||
private Instant lastActiveAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
if (lastActiveAt == null) {
|
||||
lastActiveAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -253,4 +260,12 @@ public class AppUser {
|
||||
public void setPasswordChangedAt(Instant passwordChangedAt) {
|
||||
this.passwordChangedAt = passwordChangedAt;
|
||||
}
|
||||
|
||||
public Instant getLastActiveAt() {
|
||||
return lastActiveAt;
|
||||
}
|
||||
|
||||
public void setLastActiveAt(Instant lastActiveAt) {
|
||||
this.lastActiveAt = lastActiveAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ public class PublicProfileController {
|
||||
? List.of()
|
||||
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
|
||||
|
||||
// Telefon i adres z konta pokazujemy tylko, gdy wlasciciel wlaczyl publiczny kontakt.
|
||||
boolean showContact = settings != null && settings.isPublicContactVisible();
|
||||
|
||||
return new PublicProfileResponse(
|
||||
user.getId(),
|
||||
user.getFullName(),
|
||||
@@ -80,6 +83,15 @@ public class PublicProfileController {
|
||||
user.getAccountType(),
|
||||
user.isVerified(),
|
||||
user.getCreatedAt(),
|
||||
user.getLastActiveAt(),
|
||||
settings == null ? null : settings.getCompanyTagline(),
|
||||
settings == null ? null : settings.getWebsite(),
|
||||
settings == null ? null : settings.getSocialLinkedin(),
|
||||
settings == null ? null : settings.getSocialFacebook(),
|
||||
settings == null ? null : settings.getSocialInstagram(),
|
||||
settings == null ? null : settings.getSocialYoutube(),
|
||||
showContact ? user.getPhone() : null,
|
||||
showContact ? user.getAddress() : null,
|
||||
listings.size(),
|
||||
listings.stream()
|
||||
.map(listing -> new PublicProfileResponse.PublicProfileListing(
|
||||
|
||||
@@ -20,6 +20,18 @@ public record PublicProfileResponse(
|
||||
AccountType accountType,
|
||||
boolean verified,
|
||||
Instant memberSince,
|
||||
// Ostatnia aktywnosc wlasciciela profilu - "Ostatnio aktywny/a"; null, gdy nieodnotowana.
|
||||
Instant lastActiveAt,
|
||||
// --- Dane firmowe (konto COMPANY) - null dla kont prywatnych albo gdy nieuzupelnione ---
|
||||
String companyTagline,
|
||||
String website,
|
||||
String socialLinkedin,
|
||||
String socialFacebook,
|
||||
String socialInstagram,
|
||||
String socialYoutube,
|
||||
// Telefon i adres pokazujemy tylko, gdy wlasciciel wlaczyl "publiczny kontakt"; inaczej null.
|
||||
String publicPhone,
|
||||
String publicAddress,
|
||||
int listingsCount,
|
||||
List<PublicProfileListing> listings,
|
||||
// Profil ustawiony jako prywatny, ogladany przez kogos innego niz wlasciciel. Pozostale pola
|
||||
@@ -41,6 +53,15 @@ public record PublicProfileResponse(
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
List.of(),
|
||||
true
|
||||
|
||||
@@ -45,6 +45,33 @@ public class UserSettings {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String coverImage;
|
||||
|
||||
// --- Dane firmowe na profilu publicznym (konto typu COMPANY) ---
|
||||
|
||||
// Krotkie haslo pod nazwa firmy, np. "Deweloper mieszkaniowy".
|
||||
@Column(length = 80)
|
||||
private String companyTagline;
|
||||
|
||||
@Column(length = 200)
|
||||
private String website;
|
||||
|
||||
@Column(length = 200)
|
||||
private String socialLinkedin;
|
||||
|
||||
@Column(length = 200)
|
||||
private String socialFacebook;
|
||||
|
||||
@Column(length = 200)
|
||||
private String socialInstagram;
|
||||
|
||||
@Column(length = 200)
|
||||
private String socialYoutube;
|
||||
|
||||
// true = firma zgadza sie pokazac telefon i adres (z konta) na profilu publicznym. Domyslnie
|
||||
// dane kontaktowe zostaja prywatne - kontakt idzie przez wiadomosci.
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean publicContactVisible = false;
|
||||
|
||||
// --- Preferencje prezentacji ---
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@@ -155,6 +182,62 @@ public class UserSettings {
|
||||
this.coverImage = coverImage;
|
||||
}
|
||||
|
||||
public String getCompanyTagline() {
|
||||
return companyTagline;
|
||||
}
|
||||
|
||||
public void setCompanyTagline(String companyTagline) {
|
||||
this.companyTagline = companyTagline;
|
||||
}
|
||||
|
||||
public String getWebsite() {
|
||||
return website;
|
||||
}
|
||||
|
||||
public void setWebsite(String website) {
|
||||
this.website = website;
|
||||
}
|
||||
|
||||
public String getSocialLinkedin() {
|
||||
return socialLinkedin;
|
||||
}
|
||||
|
||||
public void setSocialLinkedin(String socialLinkedin) {
|
||||
this.socialLinkedin = socialLinkedin;
|
||||
}
|
||||
|
||||
public String getSocialFacebook() {
|
||||
return socialFacebook;
|
||||
}
|
||||
|
||||
public void setSocialFacebook(String socialFacebook) {
|
||||
this.socialFacebook = socialFacebook;
|
||||
}
|
||||
|
||||
public String getSocialInstagram() {
|
||||
return socialInstagram;
|
||||
}
|
||||
|
||||
public void setSocialInstagram(String socialInstagram) {
|
||||
this.socialInstagram = socialInstagram;
|
||||
}
|
||||
|
||||
public String getSocialYoutube() {
|
||||
return socialYoutube;
|
||||
}
|
||||
|
||||
public void setSocialYoutube(String socialYoutube) {
|
||||
this.socialYoutube = socialYoutube;
|
||||
}
|
||||
|
||||
public boolean isPublicContactVisible() {
|
||||
return publicContactVisible;
|
||||
}
|
||||
|
||||
public void setPublicContactVisible(boolean publicContactVisible) {
|
||||
this.publicContactVisible = publicContactVisible;
|
||||
}
|
||||
|
||||
public Currency getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ public record UserSettingsRequest(
|
||||
ProfileVisibility profileVisibility,
|
||||
String avatarImage,
|
||||
String coverImage,
|
||||
String companyTagline,
|
||||
String website,
|
||||
String socialLinkedin,
|
||||
String socialFacebook,
|
||||
String socialInstagram,
|
||||
String socialYoutube,
|
||||
Boolean publicContactVisible,
|
||||
Currency currency,
|
||||
AreaUnit areaUnit,
|
||||
Boolean directOffersOnly,
|
||||
|
||||
@@ -6,6 +6,13 @@ public record UserSettingsResponse(
|
||||
ProfileVisibility profileVisibility,
|
||||
String avatarImage,
|
||||
String coverImage,
|
||||
String companyTagline,
|
||||
String website,
|
||||
String socialLinkedin,
|
||||
String socialFacebook,
|
||||
String socialInstagram,
|
||||
String socialYoutube,
|
||||
boolean publicContactVisible,
|
||||
Currency currency,
|
||||
AreaUnit areaUnit,
|
||||
boolean directOffersOnly,
|
||||
@@ -29,6 +36,13 @@ public record UserSettingsResponse(
|
||||
settings.getProfileVisibility(),
|
||||
settings.getAvatarImage(),
|
||||
settings.getCoverImage(),
|
||||
settings.getCompanyTagline(),
|
||||
settings.getWebsite(),
|
||||
settings.getSocialLinkedin(),
|
||||
settings.getSocialFacebook(),
|
||||
settings.getSocialInstagram(),
|
||||
settings.getSocialYoutube(),
|
||||
settings.isPublicContactVisible(),
|
||||
settings.getCurrency(),
|
||||
settings.getAreaUnit(),
|
||||
settings.isDirectOffersOnly(),
|
||||
|
||||
@@ -22,6 +22,10 @@ public class UserSettingsService {
|
||||
|
||||
private static final int MAX_BIO_LENGTH = 160;
|
||||
|
||||
private static final int MAX_TAGLINE_LENGTH = 80;
|
||||
|
||||
private static final int MAX_LINK_LENGTH = 200;
|
||||
|
||||
private final UserSettingsRepository repository;
|
||||
private final TextModerationService textModerationService;
|
||||
|
||||
@@ -78,6 +82,32 @@ public class UserSettingsService {
|
||||
if (request.coverImage() != null) {
|
||||
settings.setCoverImage(cleanImage(request.coverImage(), "Zdjęcie w tle"));
|
||||
}
|
||||
if (request.companyTagline() != null) {
|
||||
String tagline = request.companyTagline().trim();
|
||||
if (tagline.length() > MAX_TAGLINE_LENGTH) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło firmy może mieć maksymalnie " + MAX_TAGLINE_LENGTH + " znaków");
|
||||
}
|
||||
textModerationService.validateOrThrow(tagline);
|
||||
settings.setCompanyTagline(tagline.isEmpty() ? null : tagline);
|
||||
}
|
||||
if (request.website() != null) {
|
||||
settings.setWebsite(cleanLink(request.website()));
|
||||
}
|
||||
if (request.socialLinkedin() != null) {
|
||||
settings.setSocialLinkedin(cleanLink(request.socialLinkedin()));
|
||||
}
|
||||
if (request.socialFacebook() != null) {
|
||||
settings.setSocialFacebook(cleanLink(request.socialFacebook()));
|
||||
}
|
||||
if (request.socialInstagram() != null) {
|
||||
settings.setSocialInstagram(cleanLink(request.socialInstagram()));
|
||||
}
|
||||
if (request.socialYoutube() != null) {
|
||||
settings.setSocialYoutube(cleanLink(request.socialYoutube()));
|
||||
}
|
||||
if (request.publicContactVisible() != null) {
|
||||
settings.setPublicContactVisible(request.publicContactVisible());
|
||||
}
|
||||
if (request.currency() != null) {
|
||||
settings.setCurrency(request.currency());
|
||||
}
|
||||
@@ -136,6 +166,25 @@ public class UserSettingsService {
|
||||
return incoming < 0 ? null : incoming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizacja adresu www / linku social: pusty = wyczyszczone (null). Brak schematu dopisuje
|
||||
* https://, dzieki czemu link renderowany jako href jest zawsze http(s) - odcina to proby
|
||||
* wstrzykniecia "javascript:" i innych schematow, bo trafiaja za "https://".
|
||||
*/
|
||||
private String cleanLink(String value) {
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (!trimmed.matches("(?i)^https?://.*")) {
|
||||
trimmed = "https://" + trimmed;
|
||||
}
|
||||
if (trimmed.length() > MAX_LINK_LENGTH) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link może mieć maksymalnie " + MAX_LINK_LENGTH + " znaków");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String cleanImage(String dataUrl, String label) {
|
||||
String trimmed = dataUrl.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
|
||||
Reference in New Issue
Block a user