Initial commit

This commit is contained in:
MarcUs7i 2025-02-21 01:23:57 +01:00
commit 798d3e888a
11 changed files with 995 additions and 0 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.env
.git
.gitignore
.vscode
*.md
*.log

1
.env.example Normal file
View file

@ -0,0 +1 @@
MONGO_URI=mongodb://localhost:27017/uLinkShortener

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
venv/
.env

25
Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Set environment variables
ENV FLASK_APP=server.py
ENV FLASK_ENV=production
# Expose port
EXPOSE 5000
# Run the application
CMD ["python", "server.py"]

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# uLinkShortener
This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Flask, MongoDB, and Docker for quick deployment.
## Prerequisites
- Python
- MongoDB database (local or remote)
- Docker & Docker Compose (optional, for containerized deployments)
## Setup
1. Clone the repository
2. Create a virtual environment (optional):
```
python -m venv env
source env/bin/activate # Linux/Mac
env\Scripts\activate # Windows
```
3. Install dependencies:
```
pip install -r requirements.txt
```
4. Define environment variables in the `.env` file:
```
MONGO_URI=mongodb://<username>:<password>@<host>:<port>/<database>
```
## Running Locally
1. Start MongoDB
2. Run:
```
python server.py
```
3. Access the app at http://localhost:5000
## Docker Deployment
1. Build and run containers:
```
docker-compose up --build
```
2. The application will be available at http://localhost:5000

33
docker-compose.yml Normal file
View file

@ -0,0 +1,33 @@
version: '3.8'
services:
web:
build: .
ports:
- "5000:5000"
environment:
- MONGO_URI=mongodb://mongo:27017/uLinkShortener
depends_on:
- mongo
restart: unless-stopped
networks:
- app-network
mongo:
image: mongo:latest
volumes:
- mongodb_data:/data/db
ports:
- "27017:27017"
networks:
- app-network
environment:
- MONGO_INITDB_DATABASE=uLinkShortener
restart: unless-stopped
volumes:
mongodb_data:
networks:
app-network:
driver: bridge

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
flask
flask-pymongo
python-dotenv
requests

174
server.py Normal file
View file

@ -0,0 +1,174 @@
from flask import Flask, request, redirect, render_template, jsonify, make_response
from flask_pymongo import PyMongo
from datetime import datetime
import random
import string
import os
from dotenv import load_dotenv
import json
from urllib.parse import quote_plus, urlparse
load_dotenv()
app = Flask(__name__, template_folder='template', static_folder='static', static_url_path='/static')
app.config["MONGO_URI"] = os.getenv("MONGO_URI")
mongo = PyMongo(app)
def generate_short_id():
return ''.join(random.choices(string.ascii_letters + string.digits, k=8))
def get_client_info():
user_agent = request.user_agent
return {
'ip': request.remote_addr or 'Unknown',
'user_agent': user_agent.string,
'platform': request.headers.get('sec-ch-ua-platform', user_agent.platform or 'Unknown'),
'browser': user_agent.browser or 'Unknown',
'version': user_agent.version or '',
'language': request.accept_languages.best or 'Unknown',
'referrer': request.referrer or 'Direct',
'timestamp': datetime.now(),
'remote_port': request.environ.get('REMOTE_PORT', 'Unknown'),
'accept': request.headers.get('Accept', 'Unknown'),
'accept_language': request.headers.get('Accept-Language', 'Unknown'),
'accept_encoding': request.headers.get('Accept-Encoding', 'Unknown'),
'screen_size': request.headers.get('Sec-CH-UA-Platform-Screen', 'Unknown'),
'window_size': request.headers.get('Viewport-Width', 'Unknown'),
'country': request.headers.get('CF-IPCountry', 'Unknown'), # If using Cloudflare
'isp': request.headers.get('X-ISP', 'Unknown'), # Requires additional middleware
'ip_version': 'IPv6' if ':' in request.remote_addr else 'IPv4'
}
def is_valid_url(url):
if not url or url.isspace():
return False
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except:
return False
@app.route('/')
def home():
account_id = request.cookies.get('account_id')
stats = {
'total_links': mongo.db.links.count_documents({}),
'total_clicks': mongo.db.analytics.count_documents({}),
'chart_data': {
'ip_versions': list(mongo.db.analytics.aggregate([
{"$group": {"_id": "$ip_version", "count": {"$sum": 1}}},
{"$sort": {"count": -1}}
])),
'os_stats': list(mongo.db.analytics.aggregate([
{"$group": {"_id": "$platform", "count": {"$sum": 1}}},
{"$sort": {"count": -1}},
{"$limit": 10}
])),
'country_stats': list(mongo.db.analytics.aggregate([
{"$group": {"_id": "$country", "count": {"$sum": 1}}},
{"$sort": {"count": -1}},
{"$limit": 10}
])),
'isp_stats': list(mongo.db.analytics.aggregate([
{"$group": {"_id": "$isp", "count": {"$sum": 1}}},
{"$sort": {"count": -1}},
{"$limit": 10}
]))
},
'logged_in': bool(account_id)
}
return render_template('index.html', stats=stats)
@app.route('/register', methods=['POST'])
def register():
account_id = ''.join(random.choices(string.digits, k=8))
while mongo.db.users.find_one({'account_id': account_id}):
account_id = ''.join(random.choices(string.digits, k=8))
mongo.db.users.insert_one({'account_id': account_id})
return jsonify({'account_id': account_id})
@app.route('/login', methods=['POST'])
def login():
account_id = request.json.get('account_id')
user = mongo.db.users.find_one({'account_id': account_id})
if user:
response = make_response(jsonify({'success': True}))
response.set_cookie('account_id', account_id, max_age=31536000)
return response
return jsonify({'success': False}), 401
@app.route('/logout', methods=['POST'])
def logout():
response = make_response(jsonify({'success': True}))
response.delete_cookie('account_id')
return response
@app.route('/create', methods=['POST'])
def create_link():
account_id = request.json.get('account_id')
target_url = request.json.get('url')
if not mongo.db.users.find_one({'account_id': account_id}):
return jsonify({'error': 'Invalid account'}), 401
if not is_valid_url(target_url):
return jsonify({'error': 'Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)'}), 400
short_id = generate_short_id()
mongo.db.links.insert_one({
'short_id': short_id,
'target_url': target_url,
'account_id': account_id,
'created_at': datetime.now()
})
return jsonify({'short_url': f'/l/{short_id}'})
@app.route('/l/<short_id>')
def redirect_link(short_id):
link = mongo.db.links.find_one({'short_id': short_id})
if not link:
return 'Link not found', 404
client_info = get_client_info()
mongo.db.analytics.insert_one({
'link_id': short_id,
'account_id': link['account_id'],
**client_info
})
return redirect(link['target_url'])
@app.route('/analytics/<account_id>')
def get_analytics(account_id):
if not mongo.db.users.find_one({'account_id': account_id}):
return jsonify({'error': 'Invalid account'}), 401
links = list(mongo.db.links.find({'account_id': account_id}, {'_id': 0}))
analytics = list(mongo.db.analytics.find({'account_id': account_id}, {'_id': 0}))
return jsonify({
'links': links,
'analytics': analytics
})
@app.route('/delete/<short_id>', methods=['DELETE'])
def delete_link(short_id):
account_id = request.cookies.get('account_id')
if not account_id:
return jsonify({'error': 'Not logged in'}), 401
link = mongo.db.links.find_one({'short_id': short_id, 'account_id': account_id})
if not link:
return jsonify({'error': 'Link not found or unauthorized'}), 404
# Delete the link and its analytics
mongo.db.links.delete_one({'short_id': short_id})
mongo.db.analytics.delete_many({'link_id': short_id})
return jsonify({'success': True})
if __name__ == '__main__':
app.run(debug=True, host="0.0.0.0", port=5000)

319
static/script.js Normal file
View file

@ -0,0 +1,319 @@
let currentAccount = '';
let refreshInterval;
function showError(elementId, message) {
const errorElement = document.getElementById(elementId);
errorElement.textContent = message;
errorElement.style.display = 'block';
errorElement.classList.add('message-fade');
setTimeout(() => {
errorElement.style.display = 'none';
errorElement.classList.remove('message-fade');
}, 5000);
}
function showSuccess(elementId, message) {
const successElement = document.getElementById(elementId);
successElement.textContent = message;
successElement.style.display = 'block';
successElement.classList.add('message-fade');
setTimeout(() => {
successElement.style.display = 'none';
successElement.classList.remove('message-fade');
}, 5000);
}
// Check for existing login on page load
window.addEventListener('load', () => {
const accountId = document.cookie
.split('; ')
.find(row => row.startsWith('account_id='))
?.split('=')[1];
if (accountId) {
handleLogin(accountId);
}
setInterval(refreshPublicStats, 5000);
});
async function refreshPublicStats() {
const response = await fetch('/');
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const statsScript = Array.from(doc.scripts)
.find(script => script.textContent.includes('window.stats'));
if (statsScript) {
const statsMatch = statsScript.textContent.match(/window\.stats = (.*?);/);
if (statsMatch) {
window.stats = JSON.parse(statsMatch[1]);
createCharts();
}
}
}
async function register() {
const response = await fetch('/register', { method: 'POST' });
const data = await response.json();
await handleLogin(data.account_id);
}
async function handleLogin(accountId) {
currentAccount = accountId;
document.getElementById('auth-section').style.display = 'none';
document.getElementById('url-section').style.display = 'block';
document.getElementById('current-account-display').textContent = accountId;
loadAnalytics();
refreshInterval = setInterval(loadAnalytics, 5000);
}
async function login() {
const accountId = document.getElementById('account-id').value;
const response = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: accountId})
});
if (response.ok) {
await handleLogin(accountId);
} else {
showError('auth-error', 'Invalid account ID');
}
}
async function logout() {
clearInterval(refreshInterval);
const response = await fetch('/logout', { method: 'POST' });
if (response.ok) {
currentAccount = '';
document.getElementById('auth-section').style.display = 'block';
document.getElementById('url-section').style.display = 'none';
document.getElementById('account-id').value = '';
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '';
resultDiv.style.display = 'none';
}
}
function isValidUrl(url) {
if (!url || !url.trim()) return false;
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
}
async function createShortUrl() {
const url = document.getElementById('url-input').value;
const resultDiv = document.getElementById('result');
if (!isValidUrl(url)) {
showError('url-error', 'Please enter a valid URL starting with http:// or https://');
return;
}
const response = await fetch('/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
account_id: currentAccount,
url: url
})
});
const data = await response.json();
if (response.ok) {
const shortUrl = `${window.location.origin}${data.short_url}`;
showSuccess('url-success', `URL shortened successfully!`);
resultDiv.innerHTML = `<p>Short URL: <a href="${shortUrl}" target="_blank">${shortUrl}</a></p>`;
resultDiv.style.display = 'block';
document.getElementById('url-input').value = '';
loadAnalytics();
} else {
showError('url-error', data.error);
resultDiv.style.display = 'none';
}
}
let deleteCallback = null;
function showDeleteDialog(shortId) {
const dialog = document.getElementById('deleteDialog');
dialog.style.display = 'flex';
const confirmBtn = document.getElementById('confirmDelete');
deleteCallback = async () => {
const response = await fetch(`/delete/${shortId}`, { method: 'DELETE' });
if (response.ok) {
showSuccess('url-success', 'Link deleted successfully');
loadAnalytics();
} else {
const data = await response.json();
showError('url-error', data.error || 'Failed to delete link');
}
closeDeleteDialog();
};
confirmBtn.onclick = deleteCallback;
}
function closeDeleteDialog() {
const dialog = document.getElementById('deleteDialog');
dialog.style.display = 'none';
deleteCallback = null;
}
async function loadAnalytics() {
const response = await fetch(`/analytics/${currentAccount}`);
const data = await response.json();
const openDetails = Array.from(document.querySelectorAll('details[open]')).map(
detail => detail.getAttribute('data-visit-id')
);
const analyticsDiv = document.getElementById('analytics');
analyticsDiv.innerHTML = '<h2>Your Analytics</h2>';
data.links.forEach(link => {
const linkAnalytics = data.analytics.filter(a => a.link_id === link.short_id);
const clicks = linkAnalytics.length;
const shortUrl = `${window.location.origin}/l/${link.short_id}`;
analyticsDiv.innerHTML += `
<div class="link-stats">
<div class="link-header">
<h3>Short URL: <a href="${shortUrl}" target="_blank">${link.short_id}</a></h3>
<button onclick="showDeleteDialog('${link.short_id}')" class="delete-btn">Delete</button>
</div>
<p>Target: <a href="${link.target_url}" target="_blank">${link.target_url}</a></p>
<p>Total Clicks: ${clicks}</p>
<table class="analytics-table">
<thead>
<tr>
<th>Time</th>
<th>IP (Port)</th>
<th>Location</th>
<th>Device Info</th>
<th>Browser Info</th>
<th>Additional Info</th>
</tr>
</thead>
<tbody>
${linkAnalytics.map(visit => {
const visitId = `${link.short_id}-${visit.timestamp.$date || visit.timestamp}`;
return `
<tr>
<td>${new Date(visit.timestamp.$date || visit.timestamp).toLocaleString()}</td>
<td>
${visit.ip}<br>
Port: ${visit.remote_port}<br>
${visit.ip_version}
</td>
<td>
Country: ${visit.country}<br>
ISP: ${visit.isp}
</td>
<td>
OS: ${visit.platform}<br>
Screen: ${visit.screen_size}<br>
Window: ${visit.window_size}
</td>
<td>
${visit.browser} ${visit.version}<br>
Lang: ${visit.language}
</td>
<td>
<details data-visit-id="${visitId}" ${openDetails.includes(visitId) ? 'open' : ''}>
<summary>More Info</summary>
<p>User Agent: ${visit.user_agent}</p>
<p>Referrer: ${visit.referrer}</p>
<p>Accept: ${visit.accept}</p>
<p>Accept-Language: ${visit.accept_language}</p>
<p>Accept-Encoding: ${visit.accept_encoding}</p>
</details>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
});
}
function createCharts() {
if (!window.stats?.chart_data) return;
const chartConfigs = {
'ipChart': {
data: window.stats.chart_data.ip_versions,
title: 'IP Versions'
},
'osChart': {
data: window.stats.chart_data.os_stats,
title: 'Operating Systems'
},
'countryChart': {
data: window.stats.chart_data.country_stats,
title: 'Countries'
},
'ispChart': {
data: window.stats.chart_data.isp_stats,
title: 'ISPs'
}
};
Object.entries(chartConfigs).forEach(([chartId, config]) => {
const ctx = document.getElementById(chartId);
if (ctx) {
new Chart(ctx, {
type: 'pie',
data: {
labels: config.data.map(item => item._id || 'Unknown'),
datasets: [{
data: config.data.map(item => item.count),
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#4BC0C0', '#9966FF', '#C9CBCF', '#36A2EB'
]
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#ffffff'
}
},
title: {
display: true,
text: config.title,
color: '#ffffff'
}
}
}
});
}
});
}
// Add event listener to close dialog when clicking outside
document.addEventListener('DOMContentLoaded', () => {
const dialog = document.getElementById('deleteDialog');
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
closeDeleteDialog();
}
});
});

299
static/style.css Normal file
View file

@ -0,0 +1,299 @@
:root {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--primary-color: #8a2be2;
--hover-color: #9f3fff;
--input-bg: #2d2d2d;
--success-color: #4CAF50;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1, h2, h3 {
color: var(--primary-color);
}
.form-group {
margin: 20px 0;
}
input[type="text"],
input[type="url"] {
background: var(--input-bg);
border: 1px solid var(--primary-color);
color: var(--text-color);
padding: 10px;
border-radius: 4px;
width: 300px;
margin-right: 10px;
}
button {
background: var(--primary-color);
color: var(--text-color);
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background: var(--hover-color);
}
.link-stats {
background: rgba(0, 0, 0, 0.2);
padding: 15px;
margin: 10px 0;
border-radius: 5px;
border: 1px solid var(--primary-color);
}
#analytics {
margin-top: 20px;
}
#stats {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--primary-color);
}
#stats ul {
list-style: none;
padding: 0;
}
#stats li {
margin: 5px 0;
padding: 5px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
color: var(--hover-color);
text-decoration: underline;
}
#result {
margin: 20px 0;
padding: 15px;
background: rgba(0, 0, 0, 0.2);
border-radius: 5px;
border: 1px solid var(--primary-color);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.stats-card {
background: rgba(0, 0, 0, 0.2);
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.analytics-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
background: rgba(0, 0, 0, 0.1);
}
.analytics-table th,
.analytics-table td {
padding: 8px;
text-align: left;
border-bottom: 1px solid var(--primary-color);
}
.analytics-table th {
background: var(--primary-color);
color: white;
}
.analytics-table tr:hover {
background: rgba(255, 255, 255, 0.1);
}
.analytics-table td {
vertical-align: top;
}
details {
margin: 5px 0;
}
details summary {
cursor: pointer;
color: var(--primary-color);
}
details summary:hover {
color: var(--hover-color);
}
details p {
margin: 5px 0;
font-size: 0.9em;
word-break: break-all;
}
.account-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
margin-bottom: 20px;
}
.account-info span {
color: var(--text-color);
}
.account-info strong {
color: var(--primary-color);
}
.logout-btn {
background: #dc3545;
}
.logout-btn:hover {
background: #c82333;
}
.link-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.link-header h3 a {
text-decoration: none;
}
.link-header h3 a:hover {
text-decoration: underline;
}
.link-stats p a {
word-break: break-all;
}
.delete-btn {
background: #dc3545;
padding: 5px 10px;
font-size: 0.9em;
}
.delete-btn:hover {
background: #c82333;
}
.error-message {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
padding: 10px;
border-radius: 4px;
border: 1px solid #dc3545;
margin: 10px 0;
display: none;
}
.success-message {
background: rgba(40, 167, 69, 0.1);
color: #28a745;
padding: 10px;
border-radius: 4px;
border: 1px solid #28a745;
margin: 10px 0;
display: none;
}
.message-fade {
animation: fadeOut 3s forwards;
animation-delay: 2s;
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; display: none; }
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog-box {
background: var(--bg-color);
border: 1px solid var(--primary-color);
border-radius: 8px;
padding: 20px;
max-width: 400px;
width: 90%;
}
.dialog-title {
color: var(--primary-color);
margin-top: 0;
}
.dialog-message {
margin: 15px 0;
line-height: 1.4;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.dialog-buttons button {
min-width: 100px;
}
.dialog-confirm {
background: var(--primary-color);
}
.dialog-cancel {
background: #666;
}

85
template/index.html Normal file
View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<title>URL Shortener</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Pass server-side stats to frontend
window.stats = {{ stats|tojson|safe }};
document.addEventListener('DOMContentLoaded', () => {
createCharts();
});
</script>
<script src="/static/script.js"></script>
</head>
<body>
<div class="container">
<h1>URL Shortener</h1>
<div id="auth-section">
<div class="error-message" id="auth-error"></div>
<div class="form-group">
<button onclick="register()">Register</button>
<div class="form-group">
<input type="text" id="account-id" placeholder="Enter 8-digit Account ID">
<button onclick="login()">Login</button>
</div>
</div>
</div>
<div id="url-section" style="display: none;">
<div class="account-info">
<span>Account ID: <strong id="current-account-display"></strong></span>
<button onclick="logout()" class="logout-btn">Logout</button>
</div>
<div class="error-message" id="url-error"></div>
<div class="success-message" id="url-success"></div>
<div class="form-group">
<input type="url" id="url-input"
placeholder="Enter URL to shorten"
pattern="https?://.+"
title="Please enter a valid URL starting with http:// or https://"
required>
<button onclick="createShortUrl()">Shorten URL</button>
</div>
<div id="result" style="display: none;"></div>
<div id="analytics"></div>
</div>
<div id="stats">
<h2>Public Statistics</h2>
<div class="stats-grid">
<div class="stats-card">
<h3>IP Versions</h3>
<canvas id="ipChart"></canvas>
</div>
<div class="stats-card">
<h3>Operating Systems</h3>
<canvas id="osChart"></canvas>
</div>
<div class="stats-card">
<h3>Countries</h3>
<canvas id="countryChart"></canvas>
</div>
<div class="stats-card">
<h3>ISPs</h3>
<canvas id="ispChart"></canvas>
</div>
</div>
</div>
</div>
<div class="dialog-overlay" id="deleteDialog">
<div class="dialog-box">
<h3 class="dialog-title">Confirm Delete</h3>
<p class="dialog-message">Are you sure you want to delete this link? This action cannot be undone.</p>
<div class="dialog-buttons">
<button class="dialog-cancel" onclick="closeDeleteDialog()">Cancel</button>
<button class="dialog-confirm" id="confirmDelete">Delete</button>
</div>
</div>
</div>
</body>
</html>