diff --git a/.gitignore b/.gitignore index 759ae93..4fa2b83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +venv +src/static/uploads data -src/static/uploads/* +src/lovedb.db +src/__pycache__ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55488c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.14.3-alpine3.23 + +WORKDIR /app + +COPY requirements.txt . + +RUN apk add gpg + +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +EXPOSE 5000 + +ENV FLASK_APP=src/main.py +ENV FLASK_RUN_HOST=0.0.0.0 +ENV FLASK_ENV=production + +CMD ["flask", "run"] diff --git a/README.md b/README.md index f967df8..a2f8e8c 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ -Minimal dating website writen in python +# Dating-Website +## Description +Minimal dating website made in python. +It uses flask to render the HTML, saves the data in a database and also features an authentication method using PGP where the database stores the user's PGP key's fingerprint and uses that to encrypt a message only you decrypt, but successfully decrypting the message you athenticate to your account. +It's also supposed to be very easy to use, currently its still in development but the vision is that you can be enganged on the website right from the start featuring a very powerfull search page and not needing an account to use the website. +This website also does not use JavaScript making it easy to run on any browser. + +## Contributing +If you have suggestions, find bugs, or want to provide code, just open an issue before submiting a PR. I will probably not accept a PR unless I see that it's actually somewhat important, exceptions can be made, but its kinda goofy to write the code before submiting an issue. + +## Running the program +### python enviornment +`python -m venv venv` + +### Install the dependencies +`pip install -r requirements.txxt` + +### Run the program +`pyhton src/main.py` diff --git a/docker-compose.yml b/docker-compose.yml index 7885b4a..55b906c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,14 @@ - services: - db: - image: mariadb:latest - container_name: lovedb - restart: always - environment: - MARIADB_ROOT_PASSWORD: love - MARIADB_DATABASE: lovedb - MARIADB_USER: love - MARIADB_PASSWORD: love + web: + build: . + container_name: dating ports: - - "3309:3306" + - "5000:5000" volumes: - - ./data:/var/lib/mysql + - ./src:/app/src + - ./data:/app/data + environment: + - FLASK_APP=src/main.py + - FLASK_RUN_HOST=0.0.0.0 + - FLASK_ENV=development + restart: unless-stopped diff --git a/plan.md b/plan.md deleted file mode 100644 index 1b47670..0000000 --- a/plan.md +++ /dev/null @@ -1,93 +0,0 @@ -# User System Plan - -## 1. Database Schema (`User` model) - -- **Identity & Security** - - `username` (unique, required) - - `pgp` (unique, required) - - `is_verified` (boolean) - -- **Contacts** - - `xmpp` (unique, required) - - `email` (unique, optional) - - `phone` (unique, optional) - -- **Personal Info** - - `firstname` (required) - - `lastname` (required) - - `sex` (`male` / `female`, required) - - `date_of_birth` (required) - - `race` (optional) - -- **Profile & Media** - - `profile_picture` (required) - - `pictures` (optional array) - -- **Location** - - `country` (required) - - `city` (optional) - -- **Physical Attributes** - - `height` (optional float) - - `weight` (optional int) - -- **Preferences** - - `prefered_age_range` (optional) - - `likes` (optional array) - - `dislikes` (optional array) - ---- - -## 2. Registration - -1. **User fills form** - - All fields except `id`, `is_verified`. - -2. **Server receives data** - - Validate required fields and unique constraints. - - Temporarily store as **unverified**. - -3. **PGP Verification** - - Server encrypts a message with user's PGP public key. - - Show **validation page** with encrypted message. - - User decrypts message and submits. - - Server validates ownership and sets `is_verified=True`. - -4. **Create Profile Page & Redirect** - - Generate user profile page with all info. - - Redirect user to main page or search page. - ---- - -## 3. Search Page - -- **Display**: Public user profiles as cards - - Show `profile_picture`, `firstname`, `lastname`, age, `country`, `city`. - -- **Filters**: - - All fields **except** `pgp`, `id`, `username`. - - Include boolean checks for presence of `email`, `phone`, `xmpp`. - -- **Profile Click** - - Open full profile page with all info, pictures, likes/dislikes. - ---- - -## 4. Login Flow - -1. User enters `username` + `PGP key`. -2. Server verifies PGP via challenge. -3. On success: - - User can edit **all fields** execpt `id` and `username`. - - User can change pgp key, new pgp key must be verified. - ---- - -## 5. Software Stack - -- **Software used**: - - Flask (backend framework) - - MySQL (Database) - - SQLAlchemy (ORM) - - python-gnupg (PGP validation) - - Flask-WTF (Forms validation) diff --git a/requirements.txt b/requirements.txt index 9524e22..b4fccf0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ Flask Flask-SQLAlchemy SQLAlchemy -PyMySQL python-gnupg diff --git a/src/__pycache__/main.cpython-314.pyc b/src/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..a238518 Binary files /dev/null and b/src/__pycache__/main.cpython-314.pyc differ diff --git a/src/lovedb.db b/src/lovedb.db new file mode 100644 index 0000000..db65873 Binary files /dev/null and b/src/lovedb.db differ diff --git a/src/main.py b/src/main.py index 3a65e68..5e0753b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,18 +1,19 @@ from flask import Flask, render_template, request, redirect, url_for, flash, session from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import text from datetime import date import gnupg import secrets import os from werkzeug.utils import secure_filename -# defines where the upload folder is and creates it -UPLOAD_FOLDER = "static/uploads" -os.makedirs(UPLOAD_FOLDER, exist_ok=True) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # sets the base dir as the diretory where the python file is +UPLOAD_FOLDER = os.path.join(BASE_DIR, "static", "uploads") # joins the directories +os.makedirs(UPLOAD_FOLDER, exist_ok=True) # creates the uploads directorie # configures the app app = Flask(__name__) # creates de app -app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://love:love@localhost:3309/lovedb' # database connection +app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{os.path.join(BASE_DIR, 'lovedb.db')}" # database connection app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # disable track modifications (for better performance) app.config['SECRET_KEY'] = 'random' # sets the secret key used to generate random numbers app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER # sets the upload folder @@ -25,7 +26,7 @@ COUNTRIES = [ "Afghanistan","Albania","Algeria","Andorra","Angola","Antigua and "Barbados","Belarus","Belgium","Belize","Benin","Bhutan","Bolivia","Bosnia and Herzegovina", "Botswana","Brazil","Brunei","Bulgaria","Burkina Faso","Burundi","Cabo Verde","Cambodia", "Cameroon","Canada","Central African Republic","Chad","Chile","China","Colombia","Comoros", - "Congo (Congo-Brazzaville)","Costa Rica","Croatia","Cuba","Cyprus","Czechia (Czech Republic)", + "Congo (Congo-Brazzaville)","Costa Rica","Croatia","Cuba","Cyprus","Czechia (Czech Republic)", "Democratic Republic of the Congo","Denmark","Djibouti","Dominica","Dominican Republic","Ecuador", "Egypt","El Salvador","Equatorial Guinea","Eritrea","Estonia","Eswatini (fmr. Swaziland)", "Ethiopia","Fiji","Finland","France","Gabon","Gambia","Georgia","Germany","Ghana","Greece", @@ -66,6 +67,7 @@ class User(db.Model): prefered_age_range = db.Column(db.String(20), nullable=True) likes = db.Column(db.JSON, nullable=True) dislikes = db.Column(db.JSON, nullable=True) + about = db.Column(db.String(4096), nullable=True) xmpp = db.Column(db.String(128), unique=True, nullable=False) email = db.Column(db.String(128), unique=True, nullable=True) phone = db.Column(db.String(20), unique=True, nullable=True) @@ -78,26 +80,22 @@ def calculate_age(dob: date) -> int: # saves files to the upload folder and returns their URL def save_files(username: str, profile_file, pictures_files): - # creates a path for the user inside the upload forlder user_folder = os.path.join(app.config['UPLOAD_FOLDER'], username) os.makedirs(user_folder, exist_ok=True) - # prevents unsafe characters to be used in the filename profile_filename = secure_filename(profile_file.filename) profile_path = os.path.join(user_folder, profile_filename) - - # saves the profile picture to the path profile_file.save(profile_path) - profile_url = f"/{profile_path.replace(os.sep, '/')}" - # saves all of the other pictures + profile_url = f"/static/uploads/{username}/{profile_filename}" + pictures_urls = [] for pic in pictures_files: if pic.filename: filename = secure_filename(pic.filename) path = os.path.join(user_folder, filename) pic.save(path) - pictures_urls.append(f"/{path.replace(os.sep, '/')}") + pictures_urls.append(f"/static/uploads/{username}/{filename}") return profile_url, pictures_urls @@ -110,7 +108,7 @@ def pgp_encrypt_and_import(pgp_key: str, message: str): return None, None fingerprint = result.fingerprints[0] # encrypts message to the user's fingerprint - encrypted = gpg.encrypt(message, recipients=[fingerprint]) + encrypted = gpg.encrypt(message, recipients=[fingerprint], always_trust=True) if not encrypted.ok: return fingerprint, None return fingerprint, str(encrypted) @@ -119,8 +117,56 @@ def pgp_encrypt_and_import(pgp_key: str, message: str): @app.route("/") def home(): - return render_template("index.html") + query = User.query + country = request.args.get("country") + city = request.args.get("city") + sex = request.args.get("sex") + age_min = request.args.get("age_min") + age_max = request.args.get("age_max") + race = request.args.get("race") + likes = request.args.get("likes") + dislikes = request.args.get("dislikes") + + if country: + query = query.filter(User.country.ilike(f"%{country}%")) + if city: + query = query.filter(User.city.ilike(f"%{city}%")) + if sex: + query = query.filter(User.sex==sex) + if race: + query = query.filter(User.race.ilike(f"%{race}%")) + + today = date.today() + if age_min: + try: + min_age = int(age_min) + dob_max = date(today.year - min_age, today.month, today.day) + query = query.filter(User.date_of_birth <= dob_max) + except ValueError: + pass + if age_max: + try: + max_age = int(age_max) + dob_min = date(today.year - max_age - 1, today.month, today.day) + query = query.filter(User.date_of_birth >= dob_min) + except ValueError: + pass + + if likes: + likes_list = [x.strip().lower() for x in likes.split(",") if x.strip()] + users = query.all() + users = [u for u in users if u.likes and all(l in u.likes for l in likes_list)] + + if dislikes: + dislikes_list = [x.strip().lower() for x in dislikes.split(",") if x.strip()] + for dislike in dislikes_list: + query = query.filter( + text(f"JSON_CONTAINS(dislikes, '\"{dislike}\"')") + ) + + users = query.all() + return render_template("index.html", users=users, date=date) @app.route("/register", methods=["GET", "POST"]) def register(): @@ -128,9 +174,39 @@ def register(): # collect data to a dictionary data = {key: request.form.get(key) for key in [ "username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp", - "email","phone","city","height","weight","race","prefered_age_range" + "email","phone","city","height","weight","race" ]} + min_age = request.form.get("preferred_age_min") + max_age = request.form.get("preferred_age_max") + + if min_age and max_age: + try: + min_age = int(min_age) + max_age = int(max_age) + + if min_age < 18 or max_age < 18: + flash("Minimum age is 18.") + return redirect(url_for("register")) + + if min_age > max_age: + flash("Minimum age cannot be greater than maximum age.") + return redirect(url_for("register")) + + data["prefered_age_range"] = f"{min_age}-{max_age}" + + except ValueError: + flash("Invalid age range.") + return redirect(url_for("register")) + else: + data["prefered_age_range"] = None + + likes_raw = request.form.get("likes", "") + dislikes_raw = request.form.get("dislikes", "") + + data["likes"] = list(set(x.strip().lower() for x in likes_raw.split(",") if x.strip())) + data["dislikes"] = list(set(x.strip().lower() for x in dislikes_raw.split(",") if x.strip())) + # required fields required_fields = ["username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp"] if not all(data[f] for f in required_fields): @@ -169,19 +245,30 @@ def register(): # creates a random string random_string = secrets.token_hex(16) + # uses the string to create the message that wll be encrypted challenge_phrase = f"this is the unencrypted string: {random_string}" + # encrypts message fingerprint, encrypted_msg = pgp_encrypt_and_import(data["pgp"], challenge_phrase) + + # checks fingerprint if not fingerprint or not encrypted_msg: flash("Invalid PGP key or encryption failed.") return redirect(url_for("register")) + print(fingerprint) # creates a temporary session used to verify the user - session["pending_user"] = {**data, "profile_url": profile_url, "pictures_urls": pictures_urls} - session["pgp_expected_phrase"] = challenge_phrase + session["pending_user"] = { + **data, + "profile_url": profile_url, + "pictures_urls": pictures_urls, + "fingerprint": fingerprint + } + + session['pgp_expected_phrase'] = challenge_phrase # renders the verification page return render_template("verify.html", encrypted_message=encrypted_msg) @@ -191,18 +278,23 @@ def register(): @app.route("/verify", methods=["POST"]) def verify(): - # retrieve the phrase from the session - expected_phrase = session.get("pgp_expected_phrase") # retrieve user data from the session data = session.get("pending_user") + fingerprint = data.get("fingerprint") + + # retrieve the phrase from the session + expected_phrase = session.get("pgp_expected_phrase") + + # check to see if data exists if not data or not expected_phrase: flash("Session expired.") return redirect(url_for("register")) - # get the decrypted message + # get the decrypted message from form submitted = request.form.get("decrypted_message") + # check to see if submission was empty if not submitted: flash("You must paste the decrypted message.") @@ -234,6 +326,8 @@ def verify(): height=float(data["height"]) if data.get("height") else None, weight=int(data["weight"]) if data.get("weight") else None, race=data.get("race") or None, + likes=data.get("likes") or [], + dislikes=data.get("dislikes") or [], prefered_age_range=data.get("prefered_age_range") or None, is_verified=True ) @@ -244,9 +338,9 @@ def verify(): # creates login session session['user_id'] = new_user.id session['username'] = new_user.username + # remove temporary session session.pop("pending_user", None) - session.pop("pgp_expected_phrase", None) flash("PGP verification successful! Account created.") return redirect(url_for("home")) diff --git a/src/static/drip.css b/src/static/drip.css index 3f25630..5b595b4 100644 --- a/src/static/drip.css +++ b/src/static/drip.css @@ -1,28 +1,32 @@ @font-face { font-family: 'font'; - src: url('/font/font.ttf') format('truetype'); + src: url('/static/font/font.ttf') format('truetype'); font-weight: normal; font-style: normal; font-display: swap; } + @font-face { font-family: 'font'; - src: url('/font/font-Bold.ttf') format('truetype'); + src: url('/static/font/font-Bold.ttf') format('truetype'); font-weight: bold; font-style: normal; font-display: swap; } body { - background: #101210; - color: #e0e0e0; - text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8); - max-width: 800px; - margin: auto; + background: #FFE0F4; + color: #FF00AA; + text-shadow: 0px 0px 5px rgba(255, 0, 170, 0.8); + padding: 10px; + max-width: 900px; + width: 100%; + margin: auto; font-family: font; font-weight: normal; - line-height: 1.2rem; + line-height: 1.4rem; word-wrap: break-word; + font-size: 22px; } footer { @@ -31,55 +35,120 @@ footer { margin-top: auto; } -main { -} - img { - max-width: 100%; + max-width: 100%; + height: auto; } strong, b { font-weight: bold; } +section { + margin-top: 32px; + background: #fff; + padding: 10px; + border: medium dashed #FF00AA; + border-radius: 5px; +} h1 { - color: #00ff00; + color: #FF00AA; text-decoration: underline yellow; text-align: center; } -h2 { - color: #00ff00; -} - -h3 { - color: #00ff00; +h2, h3 { + color: #FF00AA; } a { - color: #ffff00; + color: #FF699B; } a:hover { color: #ffffff; } -summary { - color: #008800; - background: #101210; +table { + width: 100%; + border-collapse: collapse; + overflow-x: auto; + display: block; } -details { - background: #222; +th, td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; } -summary:hover { - color: #fff; - cursor: pointer; +th { + background-color: #FF00AA; + color: white; } -.service { - padding: 0.5rem; - border: solid thin #ffff00; +tr:nth-child(even) { + background-color: #FFB3DA; +} + +@media (max-width: 768px) { + + body { + font-size: 20px; + padding: 8px; + } + + section { + padding: 8px; + margin-top: 24px; + } + + h1 { + font-size: 1.8rem; + } + + h2 { + font-size: 1.4rem; + } + + h3 { + font-size: 1.2rem; + } + +} + +@media (max-width: 480px) { + + body { + font-size: 18px; + padding: 6px; + line-height: 1.5rem; + } + + section { + padding: 8px; + margin-top: 20px; + } + + h1 { + font-size: 1.5rem; + } + + h2 { + font-size: 1.2rem; + } + + h3 { + font-size: 1.1rem; + } + + table { + font-size: 14px; + } + + th, td { + padding: 6px; + } + } diff --git a/src/font/font-Bold.ttf b/src/static/font/font-Bold.ttf similarity index 100% rename from src/font/font-Bold.ttf rename to src/static/font/font-Bold.ttf diff --git a/src/font/font.ttf b/src/static/font/font.ttf similarity index 100% rename from src/font/font.ttf rename to src/static/font/font.ttf diff --git a/src/templates/index.html b/src/templates/index.html index 8bd4d52..575c8e6 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -1,15 +1,47 @@ {% extends "page.html" %} {% block content %} -
Page text
-Page text
-Page text
-Page text
-Page text
-Page text
-Page text
-Page text
-Page text
-Page text
+No users found.
+ {% endif %} +Enter your username and PGP public key to receive a challenge.
+Enter your username and PGP public key to receive a verification challenge.
+ + +{% with messages = get_flashed_messages() %} + {% if messages %} +Age: {{ (date.today() - user.date_of_birth).days // 365 }}
+Sex: {{ user.sex|capitalize }}
+Sex: {{ user.sex }}
-Date of Birth: {{ user.date_of_birth }}
-Age: {{ (date.today() - user.date_of_birth).days // 365 }}
-Race: {{ user.race or 'Not specified' }}
-Country: {{ user.country }}
-City: {{ user.city or 'Not specified' }}
+Date of Birth: {{ user.date_of_birth }}
+Race: {{ user.race or 'Not specified' }}
+Height: {{ user.height or 'Not specified' }} m
-Weight: {{ user.weight or 'Not specified' }} kg
-Preferred Age Range: {{ user.prefered_age_range or 'Not specified' }}
-Likes: {{ user.likes | join(', ') if user.likes else 'Not specified' }}
-Dislikes: {{ user.dislikes | join(', ') if user.dislikes else 'Not specified' }}
+Country: {{ user.country }}
+City: {{ user.city or 'Not specified' }}
+Height: + {% if user.height %} + {{ user.height }} m + {% else %} + Not specified + {% endif %} +
+ +Weight: + {% if user.weight %} + {{ user.weight }} kg + {% else %} + Not specified + {% endif %} +
+Preferred Age Range: + {{ user.prefered_age_range or 'Not specified' }} +
+ +Likes:
+ {% if user.likes %} +Not specified
+ {% endif %} + +Dislikes:
+ {% if user.dislikes %} +Not specified
+ {% endif %} +XMPP: {{ user.xmpp }}
+Email: {{ user.email or 'Not specified' }}
+Phone: {{ user.phone or 'Not specified' }}
+XMPP: {{ user.xmpp }}
-Email: {{ user.email or 'Not specified' }}
-Phone: {{ user.phone or 'Not specified' }}
{% endblock %}