This commit is contained in:
Boyan 2024-12-07 22:38:43 +01:00
parent 358b007ff9
commit 8ace6f551b
17 changed files with 744 additions and 1 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
*.db
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@ -1,3 +1,23 @@
# shortn
HTML/CSS/JS Framework Free ✅ • Lightweight ✅ • Functional ✅ • No plaintext passwords ✅
Simple link shortener with flask
Simple link shortener built with flask.
## Installation
```bash
git clone https://git.confest.im/boyan_k/shortn
pip install -r requirements.txt
```
## Usage
```bash
python app.py
```
## TODOs
- [x] basic UI (to add links)
- [x] basic auth for UI
- [x] sqlite3 to store links
- [ ] responsive?
- [ ] dockerize

144
src/app.py Normal file
View File

@ -0,0 +1,144 @@
from flask import Flask, request, redirect, url_for, render_template, flash, session, jsonify
import sqlite3
import os
from werkzeug.security import generate_password_hash, check_password_hash
import re
app = Flask(__name__)
app.secret_key = 'your_secret_key' # Replace with a strong secret key
# Database initialization
DATABASE = 'database.db'
if not os.path.exists(DATABASE):
raise FileNotFoundError(f"Database file not found. Run `python manage_users.py init` to create it.")
def init_db():
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS redirects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dest_link TEXT NOT NULL,
custom_link TEXT UNIQUE
)
''')
conn.commit()
# Helper function to interact with the database
def query_db(query, args=(), one=False):
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
cursor.execute(query, args)
rv = cursor.fetchall()
conn.commit()
return (rv[0] if rv else None) if one else rv
@app.route('/redirects', methods=['GET', 'POST'])
def redirects_list():
if 'username' not in session: # Ensure the user is logged in
return redirect(url_for('login'))
if request.method == 'POST': # Handle deletion
redirect_id = request.form.get('redirect_id')
if redirect_id:
query_db('DELETE FROM redirects WHERE id = ?', (redirect_id,))
flash('Redirect deleted successfully!')
return redirect(url_for('redirects_list'))
# Fetch all active redirects
redirects = query_db('SELECT id, dest_link, custom_link FROM redirects')
return render_template('redirects.html', redirects=redirects)
@app.route('/')
def home():
if 'username' in session:
return redirect(url_for('form'))
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
user = query_db('SELECT * FROM users WHERE username = ?', (username,), one=True)
if user and check_password_hash(user[2], password):
session['username'] = username
return redirect(url_for('form'))
else:
flash('Invalid credentials')
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('username', None)
return redirect(url_for('login'))
def verify_link(link:str) -> bool:
if not link.startswith('http://') and not link.startswith('https://'):
link = 'http://' + link
reg = r'^(https?:\/\/)(([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(:\d+)?(\/[a-zA-Z0-9@:%_\+.~#?&//=]*)?$'
return False if not re.match(reg, link) else True
@app.route('/form', methods=['GET', 'POST'])
def form():
if 'username' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
dest_link = request.form['dest_link']
if not verify_link(dest_link):
flash('Invalid URL. Make sure it starts with http:// or https://')
return render_template('nohack.html')
custom_link = request.form.get('custom_link', None)
try:
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO redirects (dest_link, custom_link) VALUES (?, ?)',
(dest_link, custom_link))
conn.commit()
flash('Redirect created successfully!')
return render_template('success.html', link=custom_link if custom_link else f"/r/{cursor.lastrowid}")
except sqlite3.IntegrityError:
flash('Custom link already exists. Try another.')
# return render_template('form.html',
# return template with user logged in flag
return render_template('form.html', user_logged_in=True)
@app.route('/r/<custom_link>')
def redirect_to_custom(custom_link):
with sqlite3.connect(DATABASE) as conn:
if result := query_db('SELECT dest_link FROM redirects WHERE custom_link = ?', (custom_link,), one=True):
# Redirect to external link
return redirect(result[0])
else:
return 'Redirect not found', 404
# For testing purpose, use a method to create users
@app.route('/create_user', methods=['POST'])
def create_user():
if request.method == 'POST':
username = request.form['username']
password = generate_password_hash(request.form['password'])
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
try:
cursor.execute('INSERT INTO users (username, password) VALUES (?, ?)', (username, password))
conn.commit()
return 'User created successfully!'
except sqlite3.IntegrityError:
return 'User already exists.'
if __name__ == '__main__':
init_db()
app.run(debug=True)

98
src/manage_users.py Normal file
View File

@ -0,0 +1,98 @@
import sqlite3
import argparse
from werkzeug.security import generate_password_hash, check_password_hash
import getpass
import sys
DATABASE = 'database.db'
# Function to initialize the database (if not already done)
def init_db():
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS redirects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dest_link TEXT NOT NULL,
custom_link TEXT UNIQUE
)
''')
conn.commit()
# Function to add a new user
def add_user(username, password):
hashed_password = generate_password_hash(password)
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
try:
cursor.execute('INSERT INTO users (username, password) VALUES (?, ?)', (username, hashed_password))
conn.commit()
print(f"User '{username}' created successfully.")
except sqlite3.IntegrityError:
print(f"Error: User '{username}' already exists.")
# Function to delete a user
def delete_user(username):
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM users WHERE username = ?', (username,))
conn.commit()
if cursor.rowcount > 0:
print(f"User '{username}' deleted successfully.")
else:
print(f"Error: User '{username}' not found.")
# Function to list all users
def list_users():
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, username FROM users')
users = cursor.fetchall()
if users:
print("Users:")
for user in users:
print(f" - ID: {user[0]}, Username: {user[1]}")
else:
print("No users found.")
# CLI argument parser
def main():
parser = argparse.ArgumentParser(description="User management CLI for the Flask app.")
parser.add_argument('command', choices=['add', 'delete', 'list', 'init'], help="Command to execute.")
parser.add_argument('--username', help="Username of the user.")
args = parser.parse_args()
# Execute the appropriate function based on the command
if args.command == 'init':
init_db()
print("Database initialized.")
elif args.command == 'add':
user = input("Enter username: ")
password = getpass.getpass("Enter password: ")
verify = getpass.getpass("Re-enter password: ")
if password != verify:
print("Error: Passwords do not match.")
sys.exit(1)
add_user(user, password)
elif args.command == 'delete':
if not args.username:
print("Error: --username is required for 'delete' command.")
else:
delete_user(args.username)
elif args.command == 'list':
try:
list_users()
except sqlite3.OperationalError:
print("Error: Database not initialized. Run 'init' command first.")
else:
print("Invalid command. Use --help for usage information.")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,46 @@
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 16px;
text-align: left;
}
table th, table td {
border: 1px solid #ddd;
padding: 8px;
}
table th {
background-color: #f4f4f4;
color: #333;
text-align: center;
}
table tr:nth-child(even) {
background-color: #f9f9f9;
}
table tr:hover {
background-color: #f1f1f1;
}
.delete-button {
background-color: #ff4d4d;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease-in-out;
display: inline-block;
margin: 0 auto;
}
.delete-button:active {
background-color: #d93636;
}
.delete-button:hover {
background-color: #d93636;
}

83
src/static/css/style.css Normal file
View File

@ -0,0 +1,83 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
body {
/* Soft gray background color */
background-color: #f5f5f5;
font-family: "Fira Code", sans-serif;
margin: 0;
padding: 0;
display:flex;
flex-direction: column;
min-height: 100vh;
margin:0;
}
nav {
background-color: #333;
color: white;
text-align: center;
display: flex;
justify-content: space-between;
top:0;
position: sticky;
padding: 10px 0;
z-index: 1000;
}
nav a {
color: white;
text-decoration: none;
padding: 10px 20px;
font-size: 16px;
transition: background-color 0.3s ease-in-out;
}
nav a:hover {
background-color: #444; /* Slightly lighter background on hover */
border-radius: 5px;
}
.container {
max-width: 1200px; /* Center the content */
width: 100%;
margin: 0 auto;
padding: 20px;
flex: 1;
}
.content {
background-color: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
margin-top: 20px;
}
footer {
background-color: #333;
color: white;
text-align: center;
padding: 10px 0;
margin-top: 40px;
/* Put on very bottom without making absolute */
}
footer a {
color: #ffc107;
text-decoration: none;
font-weight: bold;
}
footer a:hover {
text-decoration: underline;
}
.bad-wrong {
color: #ff4d4d;
}
.success {
color: #28a745;
}

View File

@ -0,0 +1,65 @@
form {
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 400px;
margin: 20px auto;
display: flex;
flex-direction: column;
gap: 15px;
}
form h1 {
font-size: 24px;
margin-bottom: 10px;
color: #333;
text-align: center;
}
form input[type="text"], form input[type="url"], form input[type="password"], form button {
width: 100%;
padding: 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px;
box-sizing: border-box;
}
form input[type="text"]:focus, form input[type="url"]:focus {
border-color: #007bff;
outline: none;
box-shadow: 0 0 4px rgba(0, 123, 255, 0.5);
}
form button {
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
font-weight: bold;
transition: background-color 0.3s ease-in-out;
}
form button:hover {
background-color: #0056b3;
}
form .error-message {
color: #ff4d4d;
font-size: 14px;
margin-top: -10px;
}
form .success-message {
color: #28a745;
font-size: 14px;
margin-top: -10px;
text-align: center;
}
form .optional {
font-size: 12px;
color: #777;
text-align: right;
}

BIN
src/static/img/cry.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
src/static/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -0,0 +1,77 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form');
const destLinkInput = form.querySelector('input[name="dest_link"]');
const customLinkInput = form.querySelector('input[name="custom_link"]');
const destLinkError = document.createElement('p');
const customLinkError = document.createElement('p');
destLinkError.classList.add('error-message');
customLinkError.classList.add('error-message');
// Append error messages right after inputs
destLinkInput.parentNode.insertBefore(destLinkError, destLinkInput.nextSibling);
customLinkInput.parentNode.insertBefore(customLinkError, customLinkInput.nextSibling);
// Function to validate URLs
const isValidURL = (url) => {
const pattern = new RegExp(
'^(https?:\\/\\/)?' + // Protocol (http or https)
'(([a-zA-Z0-9_-]+\\.)+[a-zA-Z]{2,6})' + // Domain name
'(\\/[a-zA-Z0-9@:%_\\+.~#?&//=]*)?$', // Path, query, or fragment
'i'
);
return pattern.test(url);
};
// Function to add https:// if missing
const addHttpsIfMissing = (url) => {
if (!/^https?:\/\//i.test(url)) {
return 'https://' + url;
}
return url;
};
// Validate destination link on input
destLinkInput.addEventListener('blur', () => {
// Auto-correct the URL if missing protocol
destLinkInput.value = addHttpsIfMissing(destLinkInput.value);
// Validate URL
if (!isValidURL(destLinkInput.value)) {
destLinkError.textContent = 'Please enter a valid URL (e.g., https://example.com)';
} else {
destLinkError.textContent = '';
}
});
// Validate custom link for valid characters
customLinkInput.addEventListener('input', () => {
if (customLinkInput.value && /[^a-zA-Z0-9_-]/.test(customLinkInput.value)) {
customLinkError.textContent = 'Custom link can only contain letters, numbers, dashes, and underscores.';
} else {
customLinkError.textContent = '';
}
});
// Validate form on submit
form.addEventListener('submit', (event) => {
let isValid = true;
// Auto-correct the URL if missing protocol
destLinkInput.value = addHttpsIfMissing(destLinkInput.value);
if (!isValidURL(destLinkInput.value)) {
destLinkError.textContent = 'Please enter a valid URL (e.g., https://example.com)';
isValid = false;
}
if (customLinkInput.value && /[^a-zA-Z0-9_-]/.test(customLinkInput.value)) {
customLinkError.textContent = 'Custom link can only contain letters, numbers, dashes, and underscores.';
isValid = false;
}
if (!isValid) {
event.preventDefault(); // Prevent form submission if validation fails
}
});
});

51
src/templates/base.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Shortn is a simple URL shortener service."
/>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}" />
<title>{% block title %}Shortn{% endblock %}</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}"
/>
<link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.8.1/css/all.css"
/>
<nav>
<a href="{{ url_for('form') }}">
<i class="fas fa-plus"></i> Create Redirect</a
>
<a href="{{ url_for('redirects_list') }}">
<i class="fas fa-list"></i> Manage Redirects</a
>
{% if user_logged_in %}
<a href="{{ url_for('logout') }}">
<i class="fas fa-sign-out-alt"></i> Logout</a
>
{% else %}
<a href="{{ url_for('login') }}">
<i class="fas fa-sign-in-alt"></i> Login</a
>
{% endif %}
</nav>
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
<div class="content">{% block content %}{% endblock %}</div>
</div>
<footer>
<p>Shortn 2024, <a href="confest.im">confestim</a></p>
</footer>
{% block footer %}{% endblock %}
</body>
</html>

28
src/templates/form.html Normal file
View File

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/textForm.css') }}">
{% endblock %}
{% block title %}Create Redirect{% endblock %}
{% block content %}
<h1>Create a Redirect</h1>
<form method="post">
<input type="text" name="dest_link" placeholder="https://example.com" required><br>
<input type="text" name="custom_link" placeholder="https://example2.com"><br>
<button type="submit">Create Redirect</button>
</form>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<p class="bad-wrong">
<i class="fas fa-exclamation-triangle"></i>
{{ message }}
</p>
{% endfor %}
{% endif %}
{% endwith %}
<script src="{{ url_for('static', filename='js/validLink.js') }}" defer></script>
{% endblock %}

21
src/templates/login.html Normal file
View File

@ -0,0 +1,21 @@
{% extends "base.html" %} {% block head %}
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/textForm.css') }}"
/>
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
<h1>Login</h1>
<form method="post">
<input type="text" name="username" placeholder="Username" required /><br />
<input
type="password"
name="password"
placeholder="Password"
required
/><br />
<button type="submit">Login</button>
</form>
{% with messages = get_flashed_messages() %} {% if messages %} {% for message in
messages %}
<p class="bad-wrong">{{ message }}</p>
{% endfor %} {% endif %} {% endwith %} {% endblock %}

15
src/templates/nohack.html Normal file
View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}Please Don't Hack Me{% endblock %}
{% block content %}
<h1>Please Don't Hack Me</h1>
<img src="{{ url_for('static', filename='img/cry.png') }}" alt="Crying emoji">
<p>Your request <span class="bad-wrong"> did not go through </span> Redirecting...</p>
<script>
setTimeout(() => {
window.location.href = "{{ url_for('form') }}";
}, 5000);
</script>
{% endblock %}

View File

@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/listing.css') }}">
{% endblock %}
{% block title %}Manage Redirects{% endblock %}
{% block content %}
<h1>Manage Redirects</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<p class="success"><i class="fas fa-check"></i>{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
{% if redirects %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Destination Link</th>
<th>Custom Link</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for redirect in redirects %}
<tr>
<td>{{ redirect[0] }}</td>
<td>{{ redirect[1] }}</td>
<td>
{% if redirect[2] %}
<a href="/r/{{ redirect[2] }}">{{ redirect[2] }}</a>
{% else %}
<a href="/r/{{ redirect[0] }}/">{{ redirect[0] }}</a>
{% endif %}
<td>
<form method="post" style="display: flex;">
<input type="hidden" name="redirect_id" value="{{ redirect[0] }}">
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No redirects found.</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Success{% endblock %}
{% block content %}
<h1 class="success">Link Created Successfully!</h1>
<p> <a href="/r/{{ link }}">{{ link }}</a></p>
<p> <a href="{{ url_for('form') }}">Create Another Redirect?</a></p>
{% endblock %}

28
src/test.py Normal file
View File

@ -0,0 +1,28 @@
import sqlite3
DATABASE = 'database.db'
# Function to insert links into the redirects table
def add_links():
links = [
{
"dest_link": f"https://example{num}.com",
"custom_link": f"custom{num}"
} for num in range(1, 31)
]
with sqlite3.connect(DATABASE) as conn:
cursor = conn.cursor()
for link in links:
try:
cursor.execute(
'INSERT INTO redirects (dest_link, custom_link) VALUES (?, ?)',
(link["dest_link"], link["custom_link"])
)
except sqlite3.IntegrityError:
print(f"Custom link {link['custom_link']} already exists.")
conn.commit()
print(f"Added {len(links)} links to the database.")
if __name__ == "__main__":
add_links()