From 798d3e888a46bd0edbc896b1dd4fffbd41f13b36 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:23:57 +0100 Subject: [PATCH] Initial commit --- .dockerignore | 13 ++ .env.example | 1 + .gitignore | 2 + Dockerfile | 25 ++++ README.md | 40 ++++++ docker-compose.yml | 33 +++++ requirements.txt | 4 + server.py | 174 ++++++++++++++++++++++++ static/script.js | 319 ++++++++++++++++++++++++++++++++++++++++++++ static/style.css | 299 +++++++++++++++++++++++++++++++++++++++++ template/index.html | 85 ++++++++++++ 11 files changed, 995 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 static/script.js create mode 100644 static/style.css create mode 100644 template/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..37c14e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.env +.git +.gitignore +.vscode +*.md +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..360dbd6 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +MONGO_URI=mongodb://localhost:27017/uLinkShortener diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d558a03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..efa0a05 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cc2db1 --- /dev/null +++ b/README.md @@ -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://:@:/ + ``` + +## 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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6568b0c --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed16465 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-pymongo +python-dotenv +requests \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..0182de4 --- /dev/null +++ b/server.py @@ -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/') +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/') +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/', 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) diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..b119474 --- /dev/null +++ b/static/script.js @@ -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 = `

Short URL: ${shortUrl}

`; + 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 = '

Your Analytics

'; + + 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 += ` + + `; + }); +} + +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(); + } + }); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..5116708 --- /dev/null +++ b/static/style.css @@ -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; +} \ No newline at end of file diff --git a/template/index.html b/template/index.html new file mode 100644 index 0000000..4ae0aaa --- /dev/null +++ b/template/index.html @@ -0,0 +1,85 @@ + + + + URL Shortener + + + + + + +
+

URL Shortener

+ +
+
+
+ +
+ + +
+
+
+ + + +
+

Public Statistics

+ +
+
+

IP Versions

+ +
+
+

Operating Systems

+ +
+
+

Countries

+ +
+
+

ISPs

+ +
+
+
+
+ +
+
+

Confirm Delete

+

Are you sure you want to delete this link? This action cannot be undone.

+
+ + +
+
+
+ +