forked from cleveragents/cleveragents-core
test: sim7 URL shortener — end-to-end V3 simulation (4 files)
Flask URL shortener with base62 encoding, POST /shorten, GET /<code> redirect, input validation, and HTML templates (index + error page). Multi-file extraction correctly handled nested templates/ directory.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
name: local/url-shortener
|
||||
description: >
|
||||
Build a URL shortener service in Python using Flask. Include endpoints
|
||||
to shorten a URL (POST /shorten) and redirect via short code (GET /<code>).
|
||||
Use an in-memory dictionary for storage. Generate short codes using
|
||||
base62 encoding of an auto-incrementing counter. Include input validation
|
||||
for URLs, a simple HTML landing page, and requirements.txt.
|
||||
strategy_actor: anthropic/claude-sonnet-4-20250514
|
||||
execution_actor: anthropic/claude-sonnet-4-20250514
|
||||
definition_of_done: >
|
||||
An app.py has Flask routes for POST /shorten and GET /<code>.
|
||||
Short codes are generated via base62 encoding.
|
||||
URL validation rejects malformed inputs.
|
||||
An index.html landing page allows users to paste and shorten URLs.
|
||||
In-memory dict stores mappings. A requirements.txt lists dependencies.
|
||||
@@ -0,0 +1,155 @@
|
||||
from flask import Flask, request, redirect, render_template, jsonify, url_for
|
||||
from urllib.parse import urlparse
|
||||
import re
|
||||
import threading
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# In-memory storage
|
||||
url_storage = {}
|
||||
counter = 0
|
||||
counter_lock = threading.Lock()
|
||||
|
||||
# Base62 alphabet
|
||||
BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
def base62_encode(number):
|
||||
"""Convert a number to base62 string."""
|
||||
if number == 0:
|
||||
return BASE62_ALPHABET[0]
|
||||
|
||||
result = ""
|
||||
while number > 0:
|
||||
result = BASE62_ALPHABET[number % 62] + result
|
||||
number //= 62
|
||||
return result
|
||||
|
||||
def base62_decode(string):
|
||||
"""Convert a base62 string to number."""
|
||||
number = 0
|
||||
for char in string:
|
||||
number = number * 62 + BASE62_ALPHABET.index(char)
|
||||
return number
|
||||
|
||||
def is_valid_url(url):
|
||||
"""Validate URL format and structure."""
|
||||
try:
|
||||
result = urlparse(url)
|
||||
# Check if URL has scheme and netloc
|
||||
if not all([result.scheme, result.netloc]):
|
||||
return False
|
||||
|
||||
# Check for valid scheme
|
||||
if result.scheme not in ['http', 'https']:
|
||||
return False
|
||||
|
||||
# Basic domain validation
|
||||
domain_pattern = re.compile(
|
||||
r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
|
||||
)
|
||||
|
||||
return bool(domain_pattern.match(result.netloc.split(':')[0]))
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def generate_short_code():
|
||||
"""Generate next short code using thread-safe counter."""
|
||||
global counter
|
||||
with counter_lock:
|
||||
counter += 1
|
||||
return base62_encode(counter)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Serve the main landing page."""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/shorten', methods=['POST'])
|
||||
def shorten_url():
|
||||
"""
|
||||
Shorten a URL endpoint.
|
||||
Accepts JSON or form data with 'url' field.
|
||||
"""
|
||||
# Handle both JSON and form data
|
||||
if request.is_json:
|
||||
data = request.get_json()
|
||||
url = data.get('url', '').strip()
|
||||
else:
|
||||
url = request.form.get('url', '').strip()
|
||||
|
||||
# Validate URL presence
|
||||
if not url:
|
||||
if request.is_json:
|
||||
return jsonify({'error': 'URL is required'}), 400
|
||||
return render_template('index.html', error='URL is required'), 400
|
||||
|
||||
# Validate URL format
|
||||
if not is_valid_url(url):
|
||||
error_msg = 'Invalid URL format. Please enter a valid HTTP or HTTPS URL.'
|
||||
if request.is_json:
|
||||
return jsonify({'error': error_msg}), 400
|
||||
return render_template('index.html', error=error_msg, url=url), 400
|
||||
|
||||
# Generate short code and store
|
||||
short_code = generate_short_code()
|
||||
url_storage[short_code] = url
|
||||
|
||||
# Build shortened URL
|
||||
shortened_url = url_for('redirect_url', code=short_code, _external=True)
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
'shortened_url': shortened_url,
|
||||
'short_code': short_code,
|
||||
'original_url': url
|
||||
}), 200
|
||||
|
||||
return render_template('index.html',
|
||||
shortened_url=shortened_url,
|
||||
original_url=url,
|
||||
success=True)
|
||||
|
||||
@app.route('/<code>')
|
||||
def redirect_url(code):
|
||||
"""
|
||||
Redirect to original URL using short code.
|
||||
Returns 404 if code not found.
|
||||
"""
|
||||
# Validate short code format (base62)
|
||||
if not code or not all(c in BASE62_ALPHABET for c in code):
|
||||
return render_template('error.html',
|
||||
message='Invalid short code format'), 404
|
||||
|
||||
# Look up original URL
|
||||
original_url = url_storage.get(code)
|
||||
|
||||
if not original_url:
|
||||
return render_template('error.html',
|
||||
message='Short code not found'), 404
|
||||
|
||||
return redirect(original_url, code=302)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
"""Handle 404 errors."""
|
||||
return render_template('error.html',
|
||||
message='Page not found'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 errors."""
|
||||
return render_template('error.html',
|
||||
message='Internal server error'), 500
|
||||
|
||||
# Health check endpoint
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Simple health check endpoint."""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'urls_stored': len(url_storage)
|
||||
}), 200
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
@@ -0,0 +1 @@
|
||||
Flask>=2.0.0
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - URL Shortener</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
max-width: 500px;
|
||||
margin: 100px auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: #f8f9fa;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #dc3545;
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 18px;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.home-link {
|
||||
display: inline-block;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.home-link:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<h1>❌</h1>
|
||||
<p>{{ message }}</p>
|
||||
<a href="/" class="home-link">← Go Back Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>URL Shortener</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #f8f9fa;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="url"]:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #007bff;
|
||||
margin: 10px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.result-url a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-url a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: #28a745;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔗 URL Shortener</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if success and shortened_url %}
|
||||
<div class="alert alert-success">
|
||||
<strong>Success!</strong> Your URL has been shortened.
|
||||
|
||||
<div class="result-url">
|
||||
<strong>Shortened URL:</strong><br>
|
||||
<a href="{{ shortened_url }}" target="_blank" id="shortenedUrl">{{ shortened_url }}</a>
|
||||
<br>
|
||||
<button type="button" class="copy-button" onclick="copyToClipboard()">
|
||||
📋 Copy URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px; font-size: 14px;">
|
||||
<strong>Original URL:</strong>
|
||||
<a href="{{ original_url }}" target="_blank">{{ original_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/shorten">
|
||||
<div class="form-group">
|
||||
<label for="url">Enter URL to shorten:</label>
|
||||
<input type="url"
|
||||
id="url"
|
||||
name="url"
|
||||
placeholder="https://example.com/your-long-url"
|
||||
value="{{ url if url else '' }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Shorten URL</button>
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
<p>Enter any HTTP or HTTPS URL to create a shortened version.</p>
|
||||
<p>Shortened URLs will redirect to your original link.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard() {
|
||||
const urlElement = document.getElementById('shortenedUrl');
|
||||
const url = urlElement.textContent;
|
||||
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
const button = document.querySelector('.copy-button');
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '✅ Copied!';
|
||||
button.style.background = '#28a745';
|
||||
|
||||
setTimeout(function() {
|
||||
button.textContent = originalText;
|
||||
button.style.background = '#28a745';
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = url;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
const button = document.querySelector('.copy-button');
|
||||
button.textContent = '✅ Copied!';
|
||||
setTimeout(function() {
|
||||
button.textContent = '📋 Copy URL';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user