Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.69'
version = '0.0.70'
description = 'Flextuma App'

java {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.flexcodelabs.flextuma.core.dtos;

import java.util.List;

/** Suggestions is empty when {@code available} is true -- there's nothing to suggest an
* alternative to. */
public record UsernameAvailabilityDto(String username, boolean available, List<String> suggestions) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ public interface UserRepository extends JpaRepository<User, UUID>, JpaSpecificat
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles LEFT JOIN FETCH u.roles.privileges WHERE u.username = :username")
Optional<User> findByUsername(@Param("username") String username);

// Mirrors findByUsername's eager fetch: login() needs roles+privileges loaded before the
// transaction closes (spring.jpa.open-in-view=false), whichever of the two this resolves via.
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles LEFT JOIN FETCH u.roles.privileges WHERE u.email = :email")
Optional<User> findByEmailWithRoles(@Param("email") String email);

Optional<User> findByEmail(String email);

Optional<User> findByPhoneNumber(String phoneNumber);

boolean existsByUsername(String username);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.flexcodelabs.flextuma.core.services.SmsSendResult;
import com.flexcodelabs.flextuma.core.services.SmsSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
Expand All @@ -17,6 +18,7 @@
import java.util.Map;

/** WhatsApp Cloud API text-message sender. The connector key is a Meta access token. */
@Slf4j
@Service
@RequiredArgsConstructor
public class WhatsAppSender implements SmsSender {
Expand Down Expand Up @@ -56,6 +58,28 @@ public SmsSendResult sendSms(SmsConnector config, String to, String message) {
}
}

/** Tells Meta a message was read, so the sender sees blue double-ticks. Never throws --
* this is best-effort: Meta's API hiccuping shouldn't block marking a message read locally. */
public boolean markAsRead(SmsConnector config, String providerMessageId) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(config.getKey());

Map<String, Object> body = new LinkedHashMap<>();
body.put("messaging_product", "whatsapp");
body.put("status", "read");
body.put("message_id", providerMessageId);

ResponseEntity<Map> response = restTemplate.postForEntity(messageUrl(config),
new HttpEntity<>(body, headers), Map.class);
return response.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.warn("Failed to send WhatsApp read receipt for message [{}]: {}", providerMessageId, e.getMessage());
return false;
}
}

private String messageUrl(SmsConnector config) {
String base = config.getUrl().replaceAll("/$", "");
if (base.contains("{phoneNumberId}")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.flexcodelabs.flextuma.core.services;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

import org.springframework.stereotype.Service;

import com.flexcodelabs.flextuma.core.exceptions.RateLimitExceededException;

import jakarta.servlet.http.HttpServletRequest;

/**
* Fixed-window request-volume limiter for unauthenticated public endpoints (no session/user to
* key off, so this is IP-based like {@link AuthRateLimitService}). Unlike that service -- which
* only counts failed login/registration attempts and forgives on success -- every call here
* counts against the caller's window regardless of outcome, since abuse of a public read
* endpoint (scraping, enumeration) looks like volume, not failures.
*/
@Service
public class PublicEndpointRateLimitService {

private final ConcurrentHashMap<String, AtomicInteger> requestCounts = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, LocalDateTime> windowStarts = new ConcurrentHashMap<>();

/** Throws {@link RateLimitExceededException} once {@code bucket}'s caller has made more than
* {@code maxRequests} calls within {@code windowSeconds}, otherwise records this call. */
public void checkAndRecord(String bucket, HttpServletRequest request, int maxRequests, int windowSeconds) {
String key = bucket + ":" + clientKey(request);
LocalDateTime now = LocalDateTime.now();
LocalDateTime windowStart = windowStarts.get(key);

if (windowStart == null || windowStart.isBefore(now.minusSeconds(windowSeconds))) {
windowStarts.put(key, now);
requestCounts.put(key, new AtomicInteger(1));
return;
}

int count = requestCounts.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();
if (count > maxRequests) {
long secondsRemaining = windowSeconds - Duration.between(windowStart, now).getSeconds();
throw new RateLimitExceededException("Too many requests.", Math.max(1, secondsRemaining));
}
}

private String clientKey(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}

String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}

return request.getRemoteAddr();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.flexcodelabs.flextuma.modules.auth.controllers;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.flexcodelabs.flextuma.core.dtos.UsernameAvailabilityDto;
import com.flexcodelabs.flextuma.core.services.PublicEndpointRateLimitService;
import com.flexcodelabs.flextuma.modules.auth.services.UserService;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

/** Unauthenticated signup-time lookups -- see SecurityConfig's /api/public/** matcher. Being
* unauthenticated and free of any per-user throttling elsewhere, every endpoint here must rate
* limit itself against scraping/enumeration. */
@RestController
@RequestMapping("/api/public/users")
@RequiredArgsConstructor
public class PublicUserController {

private static final String USERNAME_AVAILABILITY_BUCKET = "username-availability";

private final UserService userService;
private final PublicEndpointRateLimitService rateLimitService;

@Value("${flextuma.rate-limit.username-availability.max-requests:20}")
private int maxRequestsPerWindow;

@Value("${flextuma.rate-limit.username-availability.window-seconds:60}")
private int windowSeconds;

@GetMapping("/username-availability")
public ResponseEntity<UsernameAvailabilityDto> checkUsernameAvailability(
@RequestParam("username") String username, HttpServletRequest httpRequest) {
rateLimitService.checkAndRecord(USERNAME_AVAILABILITY_BUCKET, httpRequest, maxRequestsPerWindow, windowSeconds);
return ResponseEntity.ok(userService.checkUsernameAvailability(username));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.flexcodelabs.flextuma.modules.auth.services;

import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -21,6 +25,7 @@
import com.flexcodelabs.flextuma.core.entities.auth.User;
import com.flexcodelabs.flextuma.core.dtos.RegisterDto;
import com.flexcodelabs.flextuma.core.dtos.ProfileUpdateDto;
import com.flexcodelabs.flextuma.core.dtos.UsernameAvailabilityDto;
import com.flexcodelabs.flextuma.core.repositories.UserRepository;
import com.flexcodelabs.flextuma.core.services.BaseService;

Expand All @@ -30,6 +35,9 @@

@RequiredArgsConstructor
public class UserService extends BaseService<User> {
private static final int MAX_USERNAME_SUGGESTIONS = 5;
private static final SecureRandom RANDOM = new SecureRandom();

private final UserRepository repository;
private final PasswordEncoder passwordEncoder;

Expand Down Expand Up @@ -95,10 +103,13 @@ protected void validateDelete(User user) {
}
}

public User login(String username, String password) {
User user = repository.findByUsername(username)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN,
"Invalid username or password"));
public User login(String identifier, String password) {
boolean looksLikeEmail = identifier != null && identifier.contains("@");
Optional<User> found = looksLikeEmail
? repository.findByEmailWithRoles(identifier)
: repository.findByUsername(identifier);
User user = found.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN,
"Invalid username or password"));
if (!user.validatePassword(password)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password");
}
Expand Down Expand Up @@ -136,6 +147,41 @@ public User findByUsername(String username) {
"User with username " + username + " not found"));
}

/** Public, unauthenticated username-availability check backing the signup form's live
* validation. When taken, suggests alternatives so the caller isn't left to guess one. */
public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername) {
String username = rawUsername == null ? "" : rawUsername.trim();
if (username.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required");
}
if (username.length() > 50) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is too long");
}

boolean available = !repository.existsByUsername(username);
List<String> suggestions = available ? List.of() : generateAvailableUsernames(username);
return new UsernameAvailabilityDto(username, available, suggestions);
}

private List<String> generateAvailableUsernames(String requested) {
String base = requested.toLowerCase().replaceAll("[^a-z0-9_]", "");
if (base.isBlank()) {
base = "user";
}

List<String> suggestions = new ArrayList<>();
// Bounded so a base that happens to collide with every random suffix (astronomically
// unlikely, but not impossible) can't spin this into an unbounded loop.
int maxAttempts = MAX_USERNAME_SUGGESTIONS * 20;
for (int attempt = 0; suggestions.size() < MAX_USERNAME_SUGGESTIONS && attempt < maxAttempts; attempt++) {
String candidate = base + (1000 + RANDOM.nextInt(9000));
if (!suggestions.contains(candidate) && !repository.existsByUsername(candidate)) {
suggestions.add(candidate);
}
}
return suggestions;
}

public User register(RegisterDto request) {
repository.findByUsername(request.getUsername()).ifPresent(u -> {
throw new ResponseStatusException(HttpStatus.CONFLICT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver;
import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository;
import com.flexcodelabs.flextuma.core.security.SecurityUtils;
import com.flexcodelabs.flextuma.core.senders.WhatsAppSender;
import com.flexcodelabs.flextuma.core.services.BaseService;
import com.flexcodelabs.flextuma.modules.sms.services.SmsLogService;
import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO;
import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -32,6 +34,7 @@
import java.util.Optional;
import java.util.UUID;

@Slf4j
@Service @RequiredArgsConstructor
public class WhatsAppInboxMessageService extends BaseService<WhatsAppInboxMessage> {
private static final String SUPER_ADMIN = "SUPER_ADMIN";
Expand All @@ -40,6 +43,7 @@ public class WhatsAppInboxMessageService extends BaseService<WhatsAppInboxMessag
private final WhatsAppMediaService mediaService;
private final CurrentUserResolver currentUserResolver;
private final SmsLogService smsLogService;
private final WhatsAppSender whatsAppSender;

public record MediaContent(byte[] bytes, String mimeType) {}

Expand Down Expand Up @@ -113,11 +117,25 @@ public WhatsAppInboxMessage markAsRead(UUID id) {
if (message.getReadAt() == null) {
message.setReadAt(LocalDateTime.now());
message = repository.save(message);
sendReadReceiptToMeta(message);
}
initializeAssociationsForResponse(message);
return message;
}

/** Tells Meta the message was read, so the sender sees blue double-ticks -- previously
* markAsRead only updated our own readAt, never Meta, so ticks stayed gray forever.
* Best-effort: a failure here must not undo or fail the local read state set above. */
private void sendReadReceiptToMeta(WhatsAppInboxMessage message) {
SmsConnector connector = mediaService.resolveConnector(message.getConfig());
if (connector == null || connector.getKey() == null || connector.getUrl() == null) {
log.warn("No usable WhatsApp connector with a token found for config [{}]; skipping read receipt for message [{}]",
message.getConfig().getId(), message.getId());
return;
}
whatsAppSender.markAsRead(connector, message.getProviderMessageId());
}

private static final int CONVERSATION_SCAN_LIMIT = 2000;

/** Null when a media message has no caption; the frontend then renders a type icon + label
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ private String tenantFolder(User owner) {

/** Prefers the webhook config's explicitly linked connector; falls back to the owner's
* first active WhatsApp connector for configs that predate that link (ambiguous if the
* owner has more than one). */
private SmsConnector resolveConnector(WhatsAppWebhookConfig config) {
* owner has more than one). Package-private: also reused by WhatsAppInboxMessageService to
* resolve the connector needed to send Meta read receipts. */
SmsConnector resolveConnector(WhatsAppWebhookConfig config) {
if (config.getConnector() != null) {
return config.getConnector();
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ flextuma.sms.beem.delivery-minimum-delay-minutes=${FLEXTUMA_SMS_BEEM_DELIVERY_MI
# Public HTTPS origin used when generating per-user Meta WhatsApp callback URLs.
flextuma.public-base-url=${FLEXTUMA_PUBLIC_BASE_URL:}

# Rate limit for the unauthenticated username-availability check (per client IP).
flextuma.rate-limit.username-availability.max-requests=${FLEXTUMA_RATE_LIMIT_USERNAME_AVAILABILITY_MAX_REQUESTS:20}
flextuma.rate-limit.username-availability.window-seconds=${FLEXTUMA_RATE_LIMIT_USERNAME_AVAILABILITY_WINDOW_SECONDS:60}

# Base64-encoded 32-byte AES key. Required before connector credentials can be created or updated.
flextuma.connector-secrets.encryption-key=${FLEXTUMA_CONNECTOR_ENCRYPTION_KEY:}
# Set to 0 only for a deliberately unlimited plan. This cap applies before a shared system-connector message is charged.
Expand Down
Loading