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 %} -

Discover Users

- -
-

Search Users

-
- - - - - - - - - - - - - -
-
- -
-

Users

- {% if users %} -
- {% for user in users %} -
- - {{ user.username }}
- Age: {{ (date.today() - user.date_of_birth).days // 365 }}
- Country: {{ user.country }} -
-
- {% endfor %} -
- {% else %} -

No users found.

- {% endif %} -
+

Home

+

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 %}

Login

-

Enter your username and PGP public key to receive a verification challenge.

+

Enter your username and PGP public key to receive a challenge.

+
+

-
- Account Verification +
+

-
-

- -
-
- Paste your full public key block -
- -
- +
- - -{% with messages = get_flashed_messages() %} - {% if messages %} - - {% endif %} -{% endwith %} - {% endblock %} diff --git a/src/templates/page.html b/src/templates/page.html index cde77d1..0f95a01 100644 --- a/src/templates/page.html +++ b/src/templates/page.html @@ -3,7 +3,6 @@ Dating Website -

Dating Website

- {% block content %}{% endblock %} + +{% block content %}{% endblock %} + diff --git a/src/templates/register.html b/src/templates/register.html index 70945ae..8cf7492 100644 --- a/src/templates/register.html +++ b/src/templates/register.html @@ -5,117 +5,52 @@
-
- Account (required) +

Identity (required)

+
+
-
-

+

Personal Info (required)

+
+
-
- -
- -
- Personal Info (required) - -
-

- -
-

- -
- -

+
-
- -
+
-
- Pictures +

Profile Picture (required)

+
-
-

+

Other Pictures (optional, multiple)

+
-
- -
- -
- Location - -
- {% for c in countries %} {% endfor %} -

+
+
-
- -
+

Physical Attributes (optional)

+
+
+
-
- Physical Attributes +

Preferences (optional)

+
-
-

+

Contacts (required)

+
+
+

-
-

+ -
- -
- -
- Preferences - -
- -
-
- -
-
- -
-
- Separate with commas

- -
-
- Separate with commas -
- -
- Contacts (required) - -
-

- -
-

- -
- -
- -
-
{% with messages = get_flashed_messages() %} diff --git a/src/templates/user.html b/src/templates/user.html index eae8f80..cfa71bf 100644 --- a/src/templates/user.html +++ b/src/templates/user.html @@ -1,97 +1,37 @@ {% extends "page.html" %} {% block content %} - -
-

{{ user.firstname }} {{ user.lastname }}

- - Profile Picture

- -

Age: {{ (date.today() - user.date_of_birth).days // 365 }}

-

Sex: {{ user.sex|capitalize }}

-
- +

{{ user.firstname }} {{ user.lastname }}

+Profile Picture
{% if user.pictures %} -
-

Gallery

- {% for pic in user.pictures %} - - {% endfor %} -
+

Pictures

+{% for pic in user.pictures %} + +{% endfor %} {% endif %} +

Personal Info

+

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' }}

-
-

Personal Info

-

Date of Birth: {{ user.date_of_birth }}

-

Race: {{ user.race or 'Not specified' }}

-
+

Location

+

Country: {{ user.country }}

+

City: {{ user.city or 'Not specified' }}

+

Physical Attributes

+

Height: {{ user.height or 'Not specified' }} m

+

Weight: {{ user.weight or 'Not specified' }} kg

-
-

Location

-

Country: {{ user.country }}

-

City: {{ user.city or 'Not specified' }}

-
- - -
-

Physical Attributes

-

Height: - {% if user.height %} - {{ user.height }} m - {% else %} - Not specified - {% endif %} -

- -

Weight: - {% if user.weight %} - {{ user.weight }} kg - {% else %} - Not specified - {% endif %} -

-
- - -
-

Preferences

- -

Preferred Age Range: - {{ user.prefered_age_range or 'Not specified' }} -

- -

Likes:

- {% if user.likes %} - - {% else %} -

Not specified

- {% endif %} - -

Dislikes:

- {% if user.dislikes %} - - {% else %} -

Not specified

- {% endif %} -
- - -
-

Contacts

-

XMPP: {{ user.xmpp }}

-

Email: {{ user.email or 'Not specified' }}

-

Phone: {{ user.phone or 'Not specified' }}

-
+

Preferences

+

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' }}

+

Contacts

+

XMPP: {{ user.xmpp }}

+

Email: {{ user.email or 'Not specified' }}

+

Phone: {{ user.phone or 'Not specified' }}

{% endblock %}