diff --git a/.gitignore b/.gitignore index 5d381cc..63371f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.db + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index ce7a021..1fe9167 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ # shortn +HTML/CSS/JS Framework Free ✅ • Lightweight ✅ • Functional ✅ • No plaintext passwords ✅ -Simple link shortener with flask \ No newline at end of file + +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 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..28210bf --- /dev/null +++ b/src/app.py @@ -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/') +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) diff --git a/src/manage_users.py b/src/manage_users.py new file mode 100644 index 0000000..085b844 --- /dev/null +++ b/src/manage_users.py @@ -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() diff --git a/src/static/css/listing.css b/src/static/css/listing.css new file mode 100644 index 0000000..a288f18 --- /dev/null +++ b/src/static/css/listing.css @@ -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; +} diff --git a/src/static/css/style.css b/src/static/css/style.css new file mode 100644 index 0000000..cbe812c --- /dev/null +++ b/src/static/css/style.css @@ -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; +} \ No newline at end of file diff --git a/src/static/css/textForm.css b/src/static/css/textForm.css new file mode 100644 index 0000000..9e234e8 --- /dev/null +++ b/src/static/css/textForm.css @@ -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; +} diff --git a/src/static/img/cry.png b/src/static/img/cry.png new file mode 100644 index 0000000..ac0d3c9 Binary files /dev/null and b/src/static/img/cry.png differ diff --git a/src/static/img/favicon.png b/src/static/img/favicon.png new file mode 100644 index 0000000..b8caa19 Binary files /dev/null and b/src/static/img/favicon.png differ diff --git a/src/static/js/validLink.js b/src/static/js/validLink.js new file mode 100644 index 0000000..58538f8 --- /dev/null +++ b/src/static/js/validLink.js @@ -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 + } + }); +}); diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..92c7a2e --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,51 @@ + + + + + + + + + {% block title %}Shortn{% endblock %} + + + + + {% block head %}{% endblock %} + + +
+
{% block content %}{% endblock %}
+
+ + {% block footer %}{% endblock %} + + diff --git a/src/templates/form.html b/src/templates/form.html new file mode 100644 index 0000000..583de30 --- /dev/null +++ b/src/templates/form.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block head %} + +{% endblock %} + +{% block title %}Create Redirect{% endblock %} + +{% block content %} +

Create a Redirect

+
+
+
+ +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +

+ + {{ message }} +

+ {% endfor %} + {% endif %} + {% endwith %} + + +{% endblock %} diff --git a/src/templates/login.html b/src/templates/login.html new file mode 100644 index 0000000..1a20a2d --- /dev/null +++ b/src/templates/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} {% block head %} + +{% endblock %} {% block title %}Login{% endblock %} {% block content %} +

Login

+
+
+
+ +
+{% with messages = get_flashed_messages() %} {% if messages %} {% for message in +messages %} +

{{ message }}

+{% endfor %} {% endif %} {% endwith %} {% endblock %} diff --git a/src/templates/nohack.html b/src/templates/nohack.html new file mode 100644 index 0000000..2472e3b --- /dev/null +++ b/src/templates/nohack.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + + +{% block title %}Please Don't Hack Me{% endblock %} + +{% block content %} +

Please Don't Hack Me

+ Crying emoji +

Your request did not go through Redirecting...

+ +{% endblock %} \ No newline at end of file diff --git a/src/templates/redirects.html b/src/templates/redirects.html new file mode 100644 index 0000000..bb3651c --- /dev/null +++ b/src/templates/redirects.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} + +{% block head %} + +{% endblock %} + +{% block title %}Manage Redirects{% endblock %} + +{% block content %} +

Manage Redirects

+ + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} + + {% if redirects %} + + + + + + + + + + + {% for redirect in redirects %} + + + + + + {% endfor %} + +
IDDestination LinkCustom LinkActions
{{ redirect[0] }}{{ redirect[1] }} + {% if redirect[2] %} + {{ redirect[2] }} + {% else %} + {{ redirect[0] }} + {% endif %} + +
+ + +
+
+ {% else %} +

No redirects found.

+ {% endif %} +{% endblock %} diff --git a/src/templates/success.html b/src/templates/success.html new file mode 100644 index 0000000..27a4ed5 --- /dev/null +++ b/src/templates/success.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Success{% endblock %} + +{% block content %} + +

Link Created Successfully!

+

{{ link }}

+

Create Another Redirect?

+ +{% endblock %} \ No newline at end of file diff --git a/src/test.py b/src/test.py new file mode 100644 index 0000000..e9278eb --- /dev/null +++ b/src/test.py @@ -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()