mirror of
https://github.com/Kizuren/uLinkShortener.git
synced 2025-12-21 21:16:17 +01:00
Initial commit
This commit is contained in:
commit
798d3e888a
11 changed files with 995 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
MONGO_URI=mongodb://localhost:27017/uLinkShortener
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal 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
40
README.md
Normal 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
33
docker-compose.yml
Normal 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
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
flask
|
||||||
|
flask-pymongo
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
174
server.py
Normal file
174
server.py
Normal 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
319
static/script.js
Normal 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
299
static/style.css
Normal 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
85
template/index.html
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue