mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
beta sign up form sends email with magic token.
This commit is contained in:
@@ -1,16 +1,22 @@
|
||||
package group.goforward.battlbuilder.configuration;
|
||||
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
// Simple in-memory cache for dev/local
|
||||
return new ConcurrentMapCacheManager("gunbuilderProducts");
|
||||
// Must match the @Cacheable value(s) used in controllers/services.
|
||||
// ProductV1Controller uses: "gunbuilderProductsV1"
|
||||
return new ConcurrentMapCacheManager(
|
||||
"gunbuilderProductsV1",
|
||||
"gunbuilderProducts" // keep if anything else still references it
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,19 @@ package group.goforward.battlbuilder.controllers;
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repos.UserRepository;
|
||||
import group.goforward.battlbuilder.security.JwtService;
|
||||
import group.goforward.battlbuilder.services.auth.BetaAuthService;
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -22,17 +26,24 @@ public class AuthController {
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final BetaAuthService betaAuthService;
|
||||
|
||||
public AuthController(
|
||||
UserRepository users,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService
|
||||
JwtService jwtService,
|
||||
BetaAuthService betaAuthService
|
||||
) {
|
||||
this.users = users;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.betaAuthService = betaAuthService;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Standard Auth
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||
String email = request.getEmail().trim().toLowerCase();
|
||||
@@ -44,8 +55,7 @@ public class AuthController {
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
// Let DB generate id
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
user.setDisplayName(request.getDisplayName());
|
||||
@@ -58,14 +68,14 @@ public class AuthController {
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
AuthResponse response = new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CREATED)
|
||||
.body(new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@@ -76,11 +86,15 @@ public class AuthController {
|
||||
.orElse(null);
|
||||
|
||||
if (user == null || !user.getIsActive()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Invalid credentials");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Invalid credentials");
|
||||
}
|
||||
|
||||
user.setLastLoginAt(OffsetDateTime.now());
|
||||
@@ -90,13 +104,50 @@ public class AuthController {
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
AuthResponse response = new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
return ResponseEntity.ok(
|
||||
new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
// ---------------------------------------------------------------------
|
||||
// Beta Flow
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/beta/signup")
|
||||
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
|
||||
// Always return OK to prevent email enumeration
|
||||
try {
|
||||
betaAuthService.signup(request.getEmail(), request.getUseCase());
|
||||
} catch (Exception ignored) {
|
||||
// Intentionally swallow errors here to avoid leaking state
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/beta/confirm")
|
||||
public ResponseEntity<Map<String, Object>> betaConfirm(@RequestBody TokenRequest request) {
|
||||
try {
|
||||
betaAuthService.confirmEmail(request.getToken());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Token already used / invalid / expired -> return OK for UX
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/magic/exchange")
|
||||
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
|
||||
try {
|
||||
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
|
||||
return ResponseEntity.ok(auth);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// token invalid/expired/consumed
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Magic link is invalid or expired. Please request a new one.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_tokens",
|
||||
indexes = {
|
||||
@Index(name = "idx_auth_tokens_email", columnList = "email"),
|
||||
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
|
||||
}
|
||||
)
|
||||
public class AuthToken {
|
||||
|
||||
public enum TokenType {
|
||||
BETA_VERIFY,
|
||||
MAGIC_LOGIN
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String email;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private TokenType type;
|
||||
|
||||
@Column(name = "token_hash", nullable = false, length = 64)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private OffsetDateTime expiresAt;
|
||||
|
||||
@Column(name = "consumed_at")
|
||||
private OffsetDateTime consumedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
// getters/setters
|
||||
|
||||
public Long getId() { return id; }
|
||||
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
|
||||
public TokenType getType() { return type; }
|
||||
public void setType(TokenType type) { this.type = type; }
|
||||
|
||||
public String getTokenHash() { return tokenHash; }
|
||||
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
||||
|
||||
public OffsetDateTime getExpiresAt() { return expiresAt; }
|
||||
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
|
||||
|
||||
public OffsetDateTime getConsumedAt() { return consumedAt; }
|
||||
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
@Transient
|
||||
public boolean isConsumed() { return consumedAt != null; }
|
||||
|
||||
@Transient
|
||||
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
|
||||
}
|
||||
@@ -25,8 +25,8 @@ public class User {
|
||||
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
|
||||
private String email;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
|
||||
// password can be null for magic-link / beta users
|
||||
@Column(name = "password_hash", length = Integer.MAX_VALUE, nullable = true)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(name = "display_name", length = Integer.MAX_VALUE)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package group.goforward.battlbuilder.repos;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.HashMap;
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
@@ -31,18 +33,19 @@ public class JwtService {
|
||||
}
|
||||
|
||||
public String generateToken(User user) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES);
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("email", user.getEmail());
|
||||
claims.put("role", user.getRole());
|
||||
|
||||
if (user.getDisplayName() != null) {
|
||||
claims.put("displayName", user.getDisplayName());
|
||||
}
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(user.getUuid().toString())
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.addClaims(Map.of(
|
||||
"email", user.getEmail(),
|
||||
"role", user.getRole(),
|
||||
"displayName", user.getDisplayName()
|
||||
))
|
||||
.setClaims(claims)
|
||||
.setSubject(user.getEmail())
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(Date.from(Instant.now().plus(Duration.ofDays(7))))
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package group.goforward.battlbuilder.services.auth;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
|
||||
public interface BetaAuthService {
|
||||
|
||||
/**
|
||||
* Upsert a beta signup lead and send a confirmation email with a verify token/link.
|
||||
* Should NOT throw to the caller for common cases (e.g. already exists).
|
||||
*/
|
||||
void signup(String email, String useCase);
|
||||
|
||||
/**
|
||||
* Confirms the user's email based on a verification token.
|
||||
* Should throw if token is invalid/expired (or you can choose to no-op).
|
||||
*/
|
||||
void confirmEmail(String token);
|
||||
|
||||
/**
|
||||
* Exchanges a "magic link" token (or verified token) for a real JWT session.
|
||||
* Returns the same AuthResponse shape as /login and /register so the Next app
|
||||
* can hydrate localStorage consistently.
|
||||
*/
|
||||
AuthResponse exchangeMagicToken(String token);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package group.goforward.battlbuilder.services.auth.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repos.AuthTokenRepository;
|
||||
import group.goforward.battlbuilder.repos.UserRepository;
|
||||
import group.goforward.battlbuilder.security.JwtService;
|
||||
import group.goforward.battlbuilder.services.auth.BetaAuthService;
|
||||
import group.goforward.battlbuilder.services.utils.EmailService;
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
|
||||
private final AuthTokenRepository tokens;
|
||||
private final UserRepository users;
|
||||
private final JwtService jwtService;
|
||||
private final EmailService emailService;
|
||||
|
||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
// a secret “pepper” so token hashes aren’t trivially rainbow-table-able
|
||||
@Value("${app.authTokenPepper:change-me}")
|
||||
private String tokenPepper;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public BetaAuthServiceImpl(
|
||||
AuthTokenRepository tokens,
|
||||
UserRepository users,
|
||||
JwtService jwtService,
|
||||
EmailService emailService
|
||||
) {
|
||||
this.tokens = tokens;
|
||||
this.users = users;
|
||||
this.jwtService = jwtService;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void signup(String rawEmail, String useCase) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
// Create a verify token (24h)
|
||||
String verifyToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
|
||||
|
||||
// URL-encode token just in case (safe + avoids weirdness if you ever change format)
|
||||
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" +
|
||||
URLEncoder.encode(verifyToken, StandardCharsets.UTF_8);
|
||||
|
||||
// Keep copy simple, not phishy
|
||||
String subject = "Confirm your Battl Builders beta signup";
|
||||
String body = """
|
||||
You're on the list.
|
||||
|
||||
Confirm your email to lock in beta access:
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(confirmUrl);
|
||||
System.out.println("BETA SIGNUP: sending confirm email to " + email);
|
||||
System.out.println("BETA SIGNUP: confirmUrl = " + confirmUrl);
|
||||
emailService.sendEmail(email, subject, body);
|
||||
|
||||
// TODO (optional): persist useCase to a BetaSignup table for admin dashboards/exports
|
||||
// For now, you're fine leaving it out.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void confirmEmail(String token) {
|
||||
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
|
||||
String email = authToken.getEmail();
|
||||
|
||||
// Create or activate user
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
if (user == null) {
|
||||
user = new User();
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
user.setDisplayName(null);
|
||||
user.setRole("USER");
|
||||
user.setIsActive(true);
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
} else if (!Boolean.TRUE.equals(user.getIsActive())) {
|
||||
user.setIsActive(true);
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
}
|
||||
|
||||
// Send magic login link (15 min)
|
||||
String magicToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(15));
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" +
|
||||
URLEncoder.encode(magicToken, StandardCharsets.UTF_8);
|
||||
|
||||
String subject = "Your Battl Builders sign-in link";
|
||||
String body = """
|
||||
You're verified. Let's get you in.
|
||||
|
||||
Sign in (link expires in 15 minutes):
|
||||
%s
|
||||
""".formatted(magicUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a one-time magic link token for a JWT session.
|
||||
* Returns AuthResponse so the Next AuthContext can hydrate localStorage cleanly.
|
||||
*/
|
||||
@Override
|
||||
public AuthResponse exchangeMagicToken(String token) {
|
||||
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
|
||||
|
||||
user.setLastLoginAt(OffsetDateTime.now());
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
String jwt = jwtService.generateToken(user);
|
||||
|
||||
return new AuthResponse(
|
||||
jwt,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
);
|
||||
}
|
||||
|
||||
// -------- helpers --------
|
||||
|
||||
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
|
||||
AuthToken t = new AuthToken();
|
||||
t.setEmail(email);
|
||||
t.setType(type);
|
||||
t.setTokenHash(hashToken(token));
|
||||
t.setExpiresAt(expiresAt);
|
||||
t.setCreatedAt(OffsetDateTime.now());
|
||||
tokens.save(t);
|
||||
}
|
||||
|
||||
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
|
||||
String hash = hashToken(token);
|
||||
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
|
||||
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
|
||||
|
||||
t.setConsumedAt(now);
|
||||
tokens.save(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
private String normalizeEmail(String email) {
|
||||
if (email == null) throw new IllegalArgumentException("Email required");
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes); // 64-char hex token
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hashed);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to hash token", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package group.goforward.battlbuilder.web.dto.auth;
|
||||
|
||||
public class BetaSignupRequest {
|
||||
private String email;
|
||||
private String useCase;
|
||||
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
|
||||
public String getUseCase() { return useCase; }
|
||||
public void setUseCase(String useCase) { this.useCase = useCase; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package group.goforward.battlbuilder.web.dto.auth;
|
||||
|
||||
public class TokenRequest {
|
||||
private String token;
|
||||
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
}
|
||||
@@ -46,4 +46,8 @@ minio.bucket=battlbuilders
|
||||
minio.public-base-url=https://minio.dev.gofwd.group
|
||||
|
||||
# --- Feature toggles ---
|
||||
app.api.legacy.enabled=false
|
||||
app.api.legacy.enabled=false
|
||||
|
||||
# Beta Email Signup and Auth
|
||||
app.publicBaseUrl=http://localhost:3000
|
||||
app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb
|
||||
Reference in New Issue
Block a user