diff --git a/.gitignore b/.gitignore index 4fa2b83..759ae93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ -venv -src/static/uploads data -src/lovedb.db -src/__pycache__ +src/static/uploads/* diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 55488c7..0000000 --- a/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -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 a2f8e8c..f967df8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1 @@ -# 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` +Minimal dating website writen in python diff --git a/docker-compose.yml b/docker-compose.yml index 55b906c..7885b4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,15 @@ + services: - web: - build: . - container_name: dating - ports: - - "5000:5000" - volumes: - - ./src:/app/src - - ./data:/app/data + db: + image: mariadb:latest + container_name: lovedb + restart: always environment: - - FLASK_APP=src/main.py - - FLASK_RUN_HOST=0.0.0.0 - - FLASK_ENV=development - restart: unless-stopped + MARIADB_ROOT_PASSWORD: love + MARIADB_DATABASE: lovedb + MARIADB_USER: love + MARIADB_PASSWORD: love + ports: + - "3309:3306" + volumes: + - ./data:/var/lib/mysql diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..1b47670 --- /dev/null +++ b/plan.md @@ -0,0 +1,93 @@ +# 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 b4fccf0..9524e22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Flask Flask-SQLAlchemy SQLAlchemy +PyMySQL python-gnupg diff --git a/src/__pycache__/main.cpython-314.pyc b/src/__pycache__/main.cpython-314.pyc deleted file mode 100644 index a238518..0000000 Binary files a/src/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/src/static/font/font-Bold.ttf b/src/font/font-Bold.ttf similarity index 100% rename from src/static/font/font-Bold.ttf rename to src/font/font-Bold.ttf diff --git a/src/static/font/font.ttf b/src/font/font.ttf similarity index 100% rename from src/static/font/font.ttf rename to src/font/font.ttf diff --git a/src/lovedb.db b/src/lovedb.db deleted file mode 100644 index db65873..0000000 Binary files a/src/lovedb.db and /dev/null differ diff --git a/src/main.py b/src/main.py index 5e0753b..3a65e68 100644 --- a/src/main.py +++ b/src/main.py @@ -1,19 +1,18 @@ 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 -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 +# defines where the upload folder is and creates it +UPLOAD_FOLDER = "static/uploads" +os.makedirs(UPLOAD_FOLDER, exist_ok=True) # configures the app app = Flask(__name__) # creates de app -app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{os.path.join(BASE_DIR, 'lovedb.db')}" # database connection +app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://love:love@localhost:3309/lovedb' # 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 @@ -26,7 +25,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", @@ -67,7 +66,6 @@ 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) @@ -80,22 +78,26 @@ 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, '/')}" - profile_url = f"/static/uploads/{username}/{profile_filename}" - + # saves all of the other pictures 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"/static/uploads/{username}/{filename}") + pictures_urls.append(f"/{path.replace(os.sep, '/')}") return profile_url, pictures_urls @@ -108,7 +110,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], always_trust=True) + encrypted = gpg.encrypt(message, recipients=[fingerprint]) if not encrypted.ok: return fingerprint, None return fingerprint, str(encrypted) @@ -117,56 +119,8 @@ def pgp_encrypt_and_import(pgp_key: str, message: str): @app.route("/") def home(): - query = User.query + return render_template("index.html") - 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(): @@ -174,39 +128,9 @@ 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" + "email","phone","city","height","weight","race","prefered_age_range" ]} - 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): @@ -245,30 +169,19 @@ 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, - "fingerprint": fingerprint - } - - session['pgp_expected_phrase'] = challenge_phrase + session["pending_user"] = {**data, "profile_url": profile_url, "pictures_urls": pictures_urls} + session["pgp_expected_phrase"] = challenge_phrase # renders the verification page return render_template("verify.html", encrypted_message=encrypted_msg) @@ -278,23 +191,18 @@ def register(): @app.route("/verify", methods=["POST"]) def verify(): - # 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") - + # retrieve user data from the session + data = session.get("pending_user") # 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 from form + # get the decrypted message submitted = request.form.get("decrypted_message") - # check to see if submission was empty if not submitted: flash("You must paste the decrypted message.") @@ -326,8 +234,6 @@ 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 ) @@ -338,9 +244,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 5b595b4..3f25630 100644 --- a/src/static/drip.css +++ b/src/static/drip.css @@ -1,32 +1,28 @@ @font-face { font-family: 'font'; - src: url('/static/font/font.ttf') format('truetype'); + src: url('/font/font.ttf') format('truetype'); font-weight: normal; font-style: normal; font-display: swap; } - @font-face { font-family: 'font'; - src: url('/static/font/font-Bold.ttf') format('truetype'); + src: url('/font/font-Bold.ttf') format('truetype'); font-weight: bold; font-style: normal; font-display: swap; } body { - background: #FFE0F4; - color: #FF00AA; - text-shadow: 0px 0px 5px rgba(255, 0, 170, 0.8); - padding: 10px; - max-width: 900px; - width: 100%; - margin: auto; + background: #101210; + color: #e0e0e0; + text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8); + max-width: 800px; + margin: auto; font-family: font; font-weight: normal; - line-height: 1.4rem; + line-height: 1.2rem; word-wrap: break-word; - font-size: 22px; } footer { @@ -35,120 +31,55 @@ footer { margin-top: auto; } +main { +} + img { - max-width: 100%; - height: auto; + max-width: 100%; } strong, b { font-weight: bold; } -section { - margin-top: 32px; - background: #fff; - padding: 10px; - border: medium dashed #FF00AA; - border-radius: 5px; -} h1 { - color: #FF00AA; + color: #00ff00; text-decoration: underline yellow; text-align: center; } -h2, h3 { - color: #FF00AA; +h2 { + color: #00ff00; +} + +h3 { + color: #00ff00; } a { - color: #FF699B; + color: #ffff00; } a:hover { color: #ffffff; } -table { - width: 100%; - border-collapse: collapse; - overflow-x: auto; - display: block; +summary { + color: #008800; + background: #101210; } -th, td { - border: 1px solid #ddd; - padding: 8px; - text-align: left; +details { + background: #222; } -th { - background-color: #FF00AA; - color: white; +summary:hover { + color: #fff; + cursor: pointer; } -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; - } - +.service { + padding: 0.5rem; + border: solid thin #ffff00; } diff --git a/src/templates/index.html b/src/templates/index.html index 575c8e6..8bd4d52 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -1,47 +1,15 @@ {% extends "page.html" %} {% block content %} -
No users found.
- {% endif %} -Page text
+Page text
+Page text
+Page text
+Page text
+Page text
+Page text
+Page text
+Page text
+Page text
{% endblock %} diff --git a/src/templates/login.html b/src/templates/login.html index 0fe3b7b..f0b5116 100644 --- a/src/templates/login.html +++ b/src/templates/login.html @@ -2,34 +2,15 @@ {% block content %}Enter your username and PGP public key to receive a verification challenge.
+Enter your username and PGP public key to receive a 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' }}
-Date of Birth: {{ user.date_of_birth }}
-Race: {{ user.race or 'Not specified' }}
-Country: {{ user.country }}
+City: {{ user.city or 'Not specified' }}
+Height: {{ user.height or 'Not specified' }} m
+Weight: {{ user.weight or 'Not specified' }} kg
-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' }}
-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' }}
+XMPP: {{ user.xmpp }}
+Email: {{ user.email or 'Not specified' }}
+Phone: {{ user.phone or 'Not specified' }}
{% endblock %}