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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ ehthumbs.db

# Separate repos — managed independently
tinyurl-gui/
docs/insights/
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public RedirectController(UrlService urlService, AppProperties appProperties) {
@GetMapping("/{shortCode}")
public ResponseEntity<Void> 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
) {
Expand Down
14 changes: 12 additions & 2 deletions tinyurl/src/main/java/com/tinyurl/controller/UrlController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,8 +27,17 @@ public UrlController(UrlService urlService, AppProperties appProperties) {
}

@PostMapping
public ResponseEntity<CreateUrlResponse> create(@Valid @RequestBody CreateUrlRequest request) {
UrlMapping created = urlService.shortenUrl(request);
public ResponseEntity<CreateUrlResponse> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,14 @@ 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) {
throw new IllegalArgumentException("id must be non-negative");
}

if (id == 0) {
return "0".repeat(MIN_LENGTH);
return "0";
}

StringBuilder encoded = new StringBuilder();
Expand All @@ -28,10 +26,6 @@ public String encode(long id) {
value /= BASE;
}

while (encoded.length() < MIN_LENGTH) {
encoded.append('0');
}

return encoded.reverse().toString();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}

Expand All @@ -37,14 +49,21 @@ public UrlMappingEntity(
String originalUrl,
OffsetDateTime createdAt,
OffsetDateTime expiresAt,
boolean hasExplicitExpiry
boolean hasExplicitExpiry,
String creatorIp,
String creatorUserAgent,
String referer
) {
this.id = id;
this.shortCode = shortCode;
this.originalUrl = originalUrl;
this.createdAt = createdAt;
this.expiresAt = expiresAt;
this.hasExplicitExpiry = hasExplicitExpiry;
this.creatorIp = creatorIp;
this.creatorUserAgent = creatorUserAgent;
this.referer = referer;
this.clickCount = 0;
}

public Long getId() {
Expand All @@ -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++;
}

}
2 changes: 1 addition & 1 deletion tinyurl/src/main/java/com/tinyurl/service/UrlService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<UrlMapping> resolveCode(String code);
}
10 changes: 8 additions & 2 deletions tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -46,7 +46,10 @@ public UrlMapping shortenUrl(CreateUrlRequest request) {
request.url(),
now,
expiresAt,
hasExplicitExpiry
hasExplicitExpiry,
creatorIp,
creatorUserAgent,
referer
);

UrlMappingEntity persisted = urlRepository.save(entity);
Expand All @@ -66,6 +69,9 @@ public Optional<UrlMapping> resolveCode(String code) {
throw new GoneException("This short URL has expired or been removed.");
}

entity.incrementClickCount();
urlRepository.save(entity);

return Optional.of(toDomain(entity));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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());
}
Expand All @@ -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());
}
Expand All @@ -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());
}
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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));

Expand Down
Loading