diff --git a/.gitignore b/.gitignore index d670515..e4de535 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ ehthumbs.db # Separate repos — managed independently tinyurl-gui/ +docs/insights/ diff --git a/tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java b/tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java index 8f01877..521cca3 100644 --- a/tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java +++ b/tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java @@ -31,7 +31,7 @@ public RedirectController(UrlService urlService, AppProperties appProperties) { @GetMapping("/{shortCode}") public ResponseEntity redirect( @PathVariable - @Size(min = 6, max = 8, message = "INVALID_URL") + @Size(min = 1, max = 8, message = "INVALID_URL") @Pattern(regexp = "^[0-9A-Za-z]+$", message = "INVALID_URL") String shortCode ) { diff --git a/tinyurl/src/main/java/com/tinyurl/controller/UrlController.java b/tinyurl/src/main/java/com/tinyurl/controller/UrlController.java index c1cf91d..b357b61 100644 --- a/tinyurl/src/main/java/com/tinyurl/controller/UrlController.java +++ b/tinyurl/src/main/java/com/tinyurl/controller/UrlController.java @@ -5,6 +5,7 @@ import com.tinyurl.dto.CreateUrlResponse; import com.tinyurl.dto.UrlMapping; import com.tinyurl.service.UrlService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -26,8 +27,17 @@ public UrlController(UrlService urlService, AppProperties appProperties) { } @PostMapping - public ResponseEntity create(@Valid @RequestBody CreateUrlRequest request) { - UrlMapping created = urlService.shortenUrl(request); + public ResponseEntity create( + @Valid @RequestBody CreateUrlRequest request, + HttpServletRequest httpRequest + ) { + String ip = httpRequest.getHeader("X-Forwarded-For") != null + ? httpRequest.getHeader("X-Forwarded-For").split(",")[0].trim() + : httpRequest.getRemoteAddr(); + String userAgent = httpRequest.getHeader("User-Agent"); + String referer = httpRequest.getHeader("Referer"); + + UrlMapping created = urlService.shortenUrl(request, ip, userAgent, referer); String baseUrl = appProperties.baseUrl().endsWith("/") ? appProperties.baseUrl().substring(0, appProperties.baseUrl().length() - 1) : appProperties.baseUrl(); diff --git a/tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java b/tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java index b92528d..94c025e 100644 --- a/tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java +++ b/tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java @@ -7,8 +7,6 @@ public class Base62EncoderImpl implements Base62Encoder { private static final String CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private static final int BASE = CHARSET.length(); - private static final int MIN_LENGTH = 6; - @Override public String encode(long id) { if (id < 0) { @@ -16,7 +14,7 @@ public String encode(long id) { } if (id == 0) { - return "0".repeat(MIN_LENGTH); + return "0"; } StringBuilder encoded = new StringBuilder(); @@ -28,10 +26,6 @@ public String encode(long id) { value /= BASE; } - while (encoded.length() < MIN_LENGTH) { - encoded.append('0'); - } - return encoded.reverse().toString(); } diff --git a/tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java b/tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java index ac8c535..186250d 100644 --- a/tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java +++ b/tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java @@ -28,6 +28,18 @@ public class UrlMappingEntity { @Column(name = "has_explicit_expiry", nullable = false) private boolean hasExplicitExpiry; + @Column(name = "creator_ip", length = 45) + private String creatorIp; + + @Column(name = "creator_user_agent", length = 512) + private String creatorUserAgent; + + @Column(name = "referer", length = 2048) + private String referer; + + @Column(name = "click_count", nullable = false) + private long clickCount; + protected UrlMappingEntity() { } @@ -37,7 +49,10 @@ public UrlMappingEntity( String originalUrl, OffsetDateTime createdAt, OffsetDateTime expiresAt, - boolean hasExplicitExpiry + boolean hasExplicitExpiry, + String creatorIp, + String creatorUserAgent, + String referer ) { this.id = id; this.shortCode = shortCode; @@ -45,6 +60,10 @@ public UrlMappingEntity( this.createdAt = createdAt; this.expiresAt = expiresAt; this.hasExplicitExpiry = hasExplicitExpiry; + this.creatorIp = creatorIp; + this.creatorUserAgent = creatorUserAgent; + this.referer = referer; + this.clickCount = 0; } public Long getId() { @@ -71,4 +90,24 @@ public boolean hasExplicitExpiry() { return hasExplicitExpiry; } + public String getCreatorIp() { + return creatorIp; + } + + public String getCreatorUserAgent() { + return creatorUserAgent; + } + + public String getReferer() { + return referer; + } + + public long getClickCount() { + return clickCount; + } + + public void incrementClickCount() { + this.clickCount++; + } + } diff --git a/tinyurl/src/main/java/com/tinyurl/service/UrlService.java b/tinyurl/src/main/java/com/tinyurl/service/UrlService.java index 1ab85ac..fb46de2 100644 --- a/tinyurl/src/main/java/com/tinyurl/service/UrlService.java +++ b/tinyurl/src/main/java/com/tinyurl/service/UrlService.java @@ -5,6 +5,6 @@ import java.util.Optional; public interface UrlService { - UrlMapping shortenUrl(CreateUrlRequest request); + UrlMapping shortenUrl(CreateUrlRequest request, String creatorIp, String creatorUserAgent, String referer); Optional resolveCode(String code); } diff --git a/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java b/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java index 4aed844..2065bc6 100644 --- a/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java +++ b/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java @@ -30,7 +30,7 @@ public UrlServiceImpl(UrlRepository urlRepository, Base62Encoder base62Encoder, } @Override - public UrlMapping shortenUrl(CreateUrlRequest request) { + public UrlMapping shortenUrl(CreateUrlRequest request, String creatorIp, String creatorUserAgent, String referer) { validateUrl(request.url()); boolean hasExplicitExpiry = request.expiresInDays() != null; int expiresInDays = normalizeExpiry(request.expiresInDays()); @@ -46,7 +46,10 @@ public UrlMapping shortenUrl(CreateUrlRequest request) { request.url(), now, expiresAt, - hasExplicitExpiry + hasExplicitExpiry, + creatorIp, + creatorUserAgent, + referer ); UrlMappingEntity persisted = urlRepository.save(entity); @@ -66,6 +69,9 @@ public Optional resolveCode(String code) { throw new GoneException("This short URL has expired or been removed."); } + entity.incrementClickCount(); + urlRepository.save(entity); + return Optional.of(toDomain(entity)); } diff --git a/tinyurl/src/main/resources/db/migration/V2__strip_short_code_leading_zeros.sql b/tinyurl/src/main/resources/db/migration/V2__strip_short_code_leading_zeros.sql new file mode 100644 index 0000000..7e0d8d0 --- /dev/null +++ b/tinyurl/src/main/resources/db/migration/V2__strip_short_code_leading_zeros.sql @@ -0,0 +1,23 @@ +-- Remove leading zeros from existing short codes, relax the minimum length constraint, +-- and add creator tracking columns. + +-- Strip leading zeros from all existing short codes +UPDATE url_mappings +SET short_code = LTRIM(short_code, '0') +WHERE short_code ~ '^0+.+$'; + +-- Update the format constraint to allow codes as short as 1 character +ALTER TABLE url_mappings + DROP CONSTRAINT chk_short_code_format, + ADD CONSTRAINT chk_short_code_format CHECK (short_code ~ '^[0-9a-zA-Z_-]{1,32}$'); + +-- Add creator tracking columns +-- creator_ip: IPv4 (max 15 chars) or IPv6 (max 45 chars) of the requester +-- creator_user_agent: browser, device, or app that made the request +-- referer: page the user was on when they created the short URL +-- click_count: incremented on every successful redirect +ALTER TABLE url_mappings + ADD COLUMN creator_ip VARCHAR(45) NULL, + ADD COLUMN creator_user_agent VARCHAR(512) NULL, + ADD COLUMN referer VARCHAR(2048) NULL, + ADD COLUMN click_count BIGINT NOT NULL DEFAULT 0; diff --git a/tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java b/tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java index 24eb768..7bc1f0d 100644 --- a/tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java +++ b/tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java @@ -10,9 +10,9 @@ class Base62EncoderImplTest { private final Base62EncoderImpl encoder = new Base62EncoderImpl(); @Test - void encodeShouldPadToAtLeastSixCharacters() { - assertEquals(6, encoder.encode(1).length()); - assertEquals("000000", encoder.encode(0)); + void encodeShouldNotPadWithLeadingZeros() { + assertEquals("1", encoder.encode(1)); + assertEquals("0", encoder.encode(0)); } @Test diff --git a/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java b/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java index 5622b7c..845deed 100644 --- a/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java +++ b/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java @@ -45,7 +45,7 @@ void setUp() { void shortenUrlShouldRejectMalformedUrl() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> service.shortenUrl(new CreateUrlRequest("not-a-url", 30)) + () -> service.shortenUrl(new CreateUrlRequest("not-a-url", 30), null, null, null) ); assertEquals("INVALID_URL", ex.getMessage()); } @@ -54,7 +54,7 @@ void shortenUrlShouldRejectMalformedUrl() { void shortenUrlShouldRejectNonHttpUrl() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> service.shortenUrl(new CreateUrlRequest("ftp://example.com", 30)) + () -> service.shortenUrl(new CreateUrlRequest("ftp://example.com", 30), null, null, null) ); assertEquals("INVALID_URL", ex.getMessage()); } @@ -63,7 +63,7 @@ void shortenUrlShouldRejectNonHttpUrl() { void shortenUrlShouldRejectZeroExpiry() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> service.shortenUrl(new CreateUrlRequest("https://example.com", 0)) + () -> service.shortenUrl(new CreateUrlRequest("https://example.com", 0), null, null, null) ); assertEquals("INVALID_EXPIRY", ex.getMessage()); } @@ -72,7 +72,7 @@ void shortenUrlShouldRejectZeroExpiry() { void shortenUrlShouldRejectTooLargeExpiry() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> service.shortenUrl(new CreateUrlRequest("https://example.com", 3651)) + () -> service.shortenUrl(new CreateUrlRequest("https://example.com", 3651), null, null, null) ); assertEquals("INVALID_EXPIRY", ex.getMessage()); } @@ -84,7 +84,7 @@ void shortenUrlShouldUseDefaultExpiryAndMarkAsNonExplicit() { when(urlRepository.save(any(UrlMappingEntity.class))).thenAnswer(invocation -> invocation.getArgument(0)); OffsetDateTime before = OffsetDateTime.now(ZoneOffset.UTC); - UrlMapping result = service.shortenUrl(new CreateUrlRequest("https://example.com/default", null)); + UrlMapping result = service.shortenUrl(new CreateUrlRequest("https://example.com/default", null), "1.2.3.4", "TestAgent", null); OffsetDateTime after = OffsetDateTime.now(ZoneOffset.UTC); assertEquals("0000Ab", result.shortCode()); @@ -105,7 +105,7 @@ void shortenUrlShouldUseProvidedExpiryAndMarkAsExplicit() { when(urlRepository.save(any(UrlMappingEntity.class))).thenAnswer(invocation -> invocation.getArgument(0)); OffsetDateTime before = OffsetDateTime.now(ZoneOffset.UTC); - UrlMapping result = service.shortenUrl(new CreateUrlRequest("https://example.com/explicit", 30)); + UrlMapping result = service.shortenUrl(new CreateUrlRequest("https://example.com/explicit", 30), "1.2.3.4", "TestAgent", "https://tinyurl.buffden.com/"); OffsetDateTime after = OffsetDateTime.now(ZoneOffset.UTC); assertEquals("0000Ac", result.shortCode()); @@ -132,7 +132,8 @@ void resolveCodeShouldThrowGoneWhenExpired() { "https://example.com/expired", OffsetDateTime.now(ZoneOffset.UTC).minusDays(40), OffsetDateTime.now(ZoneOffset.UTC).minus(1, ChronoUnit.MINUTES), - true + true, + null, null, null ); when(urlRepository.findByShortCode("0000Ad")).thenReturn(Optional.of(expired));