emotki i profile
Deploy Polska Lokalnie / deploy (push) Successful in 15s

This commit is contained in:
2026-09-08 16:59:53 +02:00
parent 6b334cc1b5
commit a3e4505194
16 changed files with 1445 additions and 24 deletions
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
// 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()) {