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