diff --git a/usecases/README.md b/usecases/README.md index ac5d1e8..04fb24a 100644 --- a/usecases/README.md +++ b/usecases/README.md @@ -5,3 +5,4 @@ This repository contains fully functional, standalone implementations of the fol - [Product catalog](./product_catalog) - product catalog use case implementation using Amazon DocumentDB - [Media asset workbench](./media_asset_workbench) - media asset processing pipeline using Amazon S3 Files +- [Social Media](./social_media) - social media use case using Amazon DocumentDB diff --git a/usecases/social_media/.env.example b/usecases/social_media/.env.example new file mode 100644 index 0000000..bc1cc0c --- /dev/null +++ b/usecases/social_media/.env.example @@ -0,0 +1,4 @@ +DOCDB_URI=mongodb://:@:27017/?tls=true&tlsCAFile=global-bundle.pem&replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false +SESSION_SECRET= +FLASK_APP=app.py +FLASK_PORT=5000 diff --git a/usecases/social_media/.gitignore b/usecases/social_media/.gitignore new file mode 100644 index 0000000..7dbf746 --- /dev/null +++ b/usecases/social_media/.gitignore @@ -0,0 +1,22 @@ +# Environment variables +.env +.env.swp + +# CA certificate +global-bundle.pem +*.pem + +# Python +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +venv/ + +# OS +.DS_Store +Thumbs.db + +# Other +CLAUDE.md +.claude/ diff --git a/usecases/social_media/LICENSE b/usecases/social_media/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/usecases/social_media/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/usecases/social_media/README.md b/usecases/social_media/README.md new file mode 100644 index 0000000..a598dd9 --- /dev/null +++ b/usecases/social_media/README.md @@ -0,0 +1,126 @@ +# Social Network Application + +A simple social network application built with Flask and Amazon DocumentDB. Users can create profiles, make posts, follow other users, and view a timeline of posts from users they follow. + +## Features + +- User authentication (register, login, logout) +- User profiles with bio and display name +- Follow/unfollow functionality +- Create and delete posts +- Timeline view showing posts from followed users +- User search by username +- Server-side rendered UI with Jinja2 templates + +## Tech Stack + +- **Backend**: Python 3.x with Flask +- **Frontend**: Jinja2 templates with vanilla CSS +- **Database**: Amazon DocumentDB or any MongoDB API compatible database +- **Driver**: PyMongo + +## Setup Instructions + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Configure Environment + +Copy the example environment file and configure your DocumentDB connection: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your DocumentDB connection string and session secret: + +``` +DOCDB_URI=mongodb://:@:27017/?tls=true&tlsCAFile=global-bundle.pem&replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false +SESSION_SECRET= +FLASK_APP=app.py +FLASK_PORT=5000 +``` + +**Important**: If you need to use TLS with a CA bundle, download the Amazon RDS CA certificate: + +```bash +wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem +``` + +### 3. Seed the Database + +Create indexes and populate sample data: + +```bash +python scripts/seed.py +``` + +This will create 4 sample users and some posts. All sample users have the password `password123`. + +### 4. Run the Application + +```bash +flask run +``` + +Or directly: + +```bash +python app.py +``` + +The application will be available at `http://localhost:5000` + +## Sample Users + +After seeding, you can login with any of these accounts (password: `password123`): + +- alice@example.com (@alice) +- bob@example.com (@bob) +- charlie@example.com (@charlie) +- diana@example.com (@diana) + +## Project Structure + +``` +social/ +├── app.py # Flask app entry point +├── requirements.txt # Python dependencies +├── .env # Environment configuration (not committed) +├── routes/ +│ ├── auth.py # Login, register, logout +│ ├── users.py # Profile, follow/unfollow +│ ├── posts.py # Create, delete, timeline +│ └── search.py # User search +├── models/ +│ ├── db.py # DocumentDB connection management +│ ├── user.py # User data access +│ └── post.py # Post data access +├── templates/ +│ ├── layout.html # Base layout +│ ├── login.html +│ ├── register.html +│ ├── timeline.html +│ ├── profile.html +│ └── search.html +├── static/ +│ └── css/ +│ └── style.css +└── scripts/ + └── seed.py # Seed data for development +``` + +## Development + +The application runs in debug mode if FLASK_DEBUG is set to 1 when using `python app.py`. + +For production deployment: + +1. Set a strong `SESSION_SECRET` value +2. Disable debug mode +3. Use a production WSGI server (gunicorn, uwsgi) +4. Configure proper TLS certificates +5. Set up appropriate security groups and network access controls diff --git a/usecases/social_media/app.py b/usecases/social_media/app.py new file mode 100644 index 0000000..173378d --- /dev/null +++ b/usecases/social_media/app.py @@ -0,0 +1,28 @@ +import os +from flask import Flask, redirect, url_for +from dotenv import load_dotenv + +load_dotenv() + +app = Flask(__name__) +app.secret_key = os.getenv('SESSION_SECRET', 'dev-secret-key-change-in-production') + +from routes import auth, users, posts, search + +app.register_blueprint(auth.bp) +app.register_blueprint(users.bp) +app.register_blueprint(posts.bp) +app.register_blueprint(search.bp) + +@app.errorhandler(404) +def not_found(e): + return 'Page not found', 404 + +@app.errorhandler(500) +def server_error(e): + return 'Internal server error', 500 + +if __name__ == '__main__': + port = int(os.getenv('FLASK_PORT', 5000)) + debug = os.getenv('FLASK_DEBUG', 'false').strip().lower() in ('1', 'true', 'yes', 'on') + app.run(host='0.0.0.0', port=port, debug=debug) diff --git a/usecases/social_media/models/db.py b/usecases/social_media/models/db.py new file mode 100644 index 0000000..185b48b --- /dev/null +++ b/usecases/social_media/models/db.py @@ -0,0 +1,32 @@ +import os +from pymongo import MongoClient +from dotenv import load_dotenv + +load_dotenv() + +_client = None +_db = None + +def get_db(): + """Get database connection, creating it if necessary.""" + global _client, _db + + if _db is not None: + return _db + + docdb_uri = os.getenv('DOCDB_URI') + if not docdb_uri: + raise ValueError('DOCDB_URI environment variable not set') + + _client = MongoClient(docdb_uri) + _db = _client.social + + return _db + +def close_db(): + """Close database connection.""" + global _client, _db + if _client is not None: + _client.close() + _client = None + _db = None diff --git a/usecases/social_media/models/post.py b/usecases/social_media/models/post.py new file mode 100644 index 0000000..26b0efb --- /dev/null +++ b/usecases/social_media/models/post.py @@ -0,0 +1,80 @@ +from datetime import datetime +from bson import ObjectId +from models.db import get_db + +def create_post(author_id, content): + """Create a new post.""" + db = get_db() + + if isinstance(author_id, str): + author_id = ObjectId(author_id) + + post_doc = { + 'author_id': author_id, + 'content': content, + 'created_at': datetime.utcnow() + } + + result = db.posts.insert_one(post_doc) + return result.inserted_id + +def delete_post(post_id, author_id): + """Delete a post (only if owned by author).""" + db = get_db() + + if isinstance(post_id, str): + post_id = ObjectId(post_id) + if isinstance(author_id, str): + author_id = ObjectId(author_id) + + result = db.posts.delete_one({ + '_id': post_id, + 'author_id': author_id + }) + + return result.deleted_count > 0 + +def get_user_posts(user_id, limit=50): + """Get posts by a specific user.""" + db = get_db() + + if isinstance(user_id, str): + user_id = ObjectId(user_id) + + posts = list(db.posts.find( + {'author_id': user_id} + ).sort('created_at', -1).limit(limit)) + + author = db.users.find_one({'_id': user_id}) + + for post in posts: + post['author'] = author + + return posts + +def get_timeline_posts(user_id, limit=50): + """Get posts from users that the current user is following.""" + db = get_db() + + if isinstance(user_id, str): + user_id = ObjectId(user_id) + + user = db.users.find_one({'_id': user_id}) + if not user: + return [] + + following_ids = user.get('following', []) + following_ids.append(user_id) + + posts = list(db.posts.find( + {'author_id': {'$in': following_ids}} + ).sort('created_at', -1).limit(limit)) + + author_ids = list(set(post['author_id'] for post in posts)) + authors = list(db.users.find({'_id': {'$in': author_ids}})) + authors_dict = {author['_id']: author for author in authors} + + for post in posts: + post['author'] = authors_dict.get(post['author_id']) + + return posts diff --git a/usecases/social_media/models/user.py b/usecases/social_media/models/user.py new file mode 100644 index 0000000..cb05cf9 --- /dev/null +++ b/usecases/social_media/models/user.py @@ -0,0 +1,181 @@ +from datetime import datetime +from bson import ObjectId +from werkzeug.security import generate_password_hash, check_password_hash +from models.db import get_db +import re + +def create_user(username, email, password, display_name=None, bio=None): + """Create a new user account.""" + db = get_db() + + if db.users.find_one({'username': username}): + raise ValueError('Username already exists') + + if db.users.find_one({'email': email}): + raise ValueError('Email already exists') + + user_doc = { + 'username': username, + 'email': email, + 'password_hash': generate_password_hash(password), + 'display_name': display_name or username, + 'bio': bio or '', + 'followers': [], + 'following': [], + 'created_at': datetime.utcnow() + } + + result = db.users.insert_one(user_doc) + return result.inserted_id + +def find_user_by_username(username): + """Find a user by username.""" + db = get_db() + return db.users.find_one({'username': username}) + +def find_user_by_email(email): + """Find a user by email.""" + db = get_db() + return db.users.find_one({'email': email}) + +def get_user_by_id(user_id): + """Get a user by ID.""" + db = get_db() + if isinstance(user_id, str): + user_id = ObjectId(user_id) + return db.users.find_one({'_id': user_id}) + +def verify_password(user, password): + """Verify a user's password.""" + return check_password_hash(user['password_hash'], password) + +def follow_user(follower_id, followee_id): + """Follow a user.""" + db = get_db() + + if isinstance(follower_id, str): + follower_id = ObjectId(follower_id) + if isinstance(followee_id, str): + followee_id = ObjectId(followee_id) + + if follower_id == followee_id: + raise ValueError('Cannot follow yourself') + + db.users.update_one( + {'_id': follower_id}, + {'$addToSet': {'following': followee_id}} + ) + + db.users.update_one( + {'_id': followee_id}, + {'$addToSet': {'followers': follower_id}} + ) + +def unfollow_user(follower_id, followee_id): + """Unfollow a user.""" + db = get_db() + + if isinstance(follower_id, str): + follower_id = ObjectId(follower_id) + if isinstance(followee_id, str): + followee_id = ObjectId(followee_id) + + db.users.update_one( + {'_id': follower_id}, + {'$pull': {'following': followee_id}} + ) + + db.users.update_one( + {'_id': followee_id}, + {'$pull': {'followers': follower_id}} + ) + +def get_followers(user_id): + """Get list of followers for a user.""" + db = get_db() + + if isinstance(user_id, str): + user_id = ObjectId(user_id) + + user = db.users.find_one({'_id': user_id}) + if not user: + return [] + + follower_ids = user.get('followers', []) + return list(db.users.find({'_id': {'$in': follower_ids}})) + +def get_following(user_id): + """Get list of users that a user is following.""" + db = get_db() + + if isinstance(user_id, str): + user_id = ObjectId(user_id) + + user = db.users.find_one({'_id': user_id}) + if not user: + return [] + + following_ids = user.get('following', []) + return list(db.users.find({'_id': {'$in': following_ids}})) + +def search_users(query): + """Search for users by username (exact match only).""" + db = get_db() + + if not query or not isinstance(query, str): + return [] + + sanitized_query = query.strip() + + if not sanitized_query: + return [] + + user = db.users.find_one({'username': sanitized_query}) + return [user] if user else [] + +def is_following(follower_id, followee_id): + """Check if a user is following another user.""" + db = get_db() + + if isinstance(follower_id, str): + follower_id = ObjectId(follower_id) + if isinstance(followee_id, str): + followee_id = ObjectId(followee_id) + + user = db.users.find_one({'_id': follower_id}) + if not user: + return False + + return followee_id in user.get('following', []) + +def validate_password(password): + """Validate password meets requirements.""" + if len(password) < 8: + raise ValueError('Password must be at least 8 characters long') + + if not re.search(r'[A-Za-z]', password): + raise ValueError('Password must contain at least one letter') + + if not re.search(r'\d', password): + raise ValueError('Password must contain at least one number') + +def change_password(user_id, current_password, new_password): + """Change a user's password.""" + db = get_db() + + if isinstance(user_id, str): + user_id = ObjectId(user_id) + + user = db.users.find_one({'_id': user_id}) + if not user: + raise ValueError('User not found') + + if not check_password_hash(user['password_hash'], current_password): + raise ValueError('Current password is incorrect') + + validate_password(new_password) + + db.users.update_one( + {'_id': user_id}, + {'$set': {'password_hash': generate_password_hash(new_password)}} + ) diff --git a/usecases/social_media/requirements.txt b/usecases/social_media/requirements.txt new file mode 100644 index 0000000..86225ef --- /dev/null +++ b/usecases/social_media/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.2 +pymongo==4.6.2 +python-dotenv==1.0.1 diff --git a/usecases/social_media/routes/auth.py b/usecases/social_media/routes/auth.py new file mode 100644 index 0000000..3364075 --- /dev/null +++ b/usecases/social_media/routes/auth.py @@ -0,0 +1,80 @@ +from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from models import user as user_model + +bp = Blueprint('auth', __name__) + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username_or_email = request.form.get('username') + password = request.form.get('password') + + user = user_model.find_user_by_username(username_or_email) + if not user: + user = user_model.find_user_by_email(username_or_email) + + if user and user_model.verify_password(user, password): + session['user_id'] = str(user['_id']) + session['username'] = user['username'] + flash('Successfully logged in!', 'success') + return redirect(url_for('posts.timeline')) + else: + flash('Invalid username or password', 'error') + + return render_template('login.html') + +@bp.route('/register', methods=['GET', 'POST']) +def register(): + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + display_name = request.form.get('display_name') + bio = request.form.get('bio') + + try: + user_id = user_model.create_user( + username=username, + email=email, + password=password, + display_name=display_name, + bio=bio + ) + session['user_id'] = str(user_id) + session['username'] = username + flash('Account created successfully!', 'success') + return redirect(url_for('posts.timeline')) + except ValueError as e: + flash(str(e), 'error') + + return render_template('register.html') + +@bp.route('/logout') +def logout(): + session.clear() + flash('Successfully logged out', 'success') + return redirect(url_for('auth.login')) + +@bp.route('/change-password', methods=['GET', 'POST']) +def change_password(): + if 'user_id' not in session: + flash('Please log in to change your password', 'error') + return redirect(url_for('auth.login')) + + if request.method == 'POST': + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + if new_password != confirm_password: + flash('New passwords do not match', 'error') + return render_template('change_password.html') + + try: + user_model.change_password(session['user_id'], current_password, new_password) + flash('Password changed successfully!', 'success') + return redirect(url_for('posts.timeline')) + except ValueError as e: + flash(str(e), 'error') + + return render_template('change_password.html') diff --git a/usecases/social_media/routes/posts.py b/usecases/social_media/routes/posts.py new file mode 100644 index 0000000..90468f4 --- /dev/null +++ b/usecases/social_media/routes/posts.py @@ -0,0 +1,61 @@ +from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from urllib.parse import urlparse +from models import post as post_model +from utils.decorators import login_required + +bp = Blueprint('posts', __name__) + +def _is_safe_local_redirect_target(target): + """Allow only local in-app redirect targets (path-only).""" + if not target: + return False + + normalized_target = target.replace('\\', '') + + # Only allow absolute local paths (e.g. "/timeline"), never external URLs. + if not normalized_target.startswith('/'): + return False + # Reject protocol-relative targets like "//evil.example". + if normalized_target.startswith('//'): + return False + + parsed = urlparse(normalized_target) + return not parsed.scheme and not parsed.netloc + +@bp.route('/') +@bp.route('/timeline') +@login_required +def timeline(): + user_id = session.get('user_id') + posts = post_model.get_timeline_posts(user_id) + return render_template('timeline.html', posts=posts) + +@bp.route('/post', methods=['POST']) +@login_required +def create_post(): + content = request.form.get('content') + + if not content or not content.strip(): + flash('Post content cannot be empty', 'error') + return redirect(url_for('posts.timeline')) + + user_id = session.get('user_id') + post_model.create_post(user_id, content) + flash('Post created!', 'success') + return redirect(url_for('posts.timeline')) + +@bp.route('/delete/', methods=['POST']) +@login_required +def delete_post(post_id): + user_id = session.get('user_id') + deleted = post_model.delete_post(post_id, user_id) + + if deleted: + flash('Post deleted', 'success') + else: + flash('Could not delete post', 'error') + + referrer = request.referrer + if _is_safe_local_redirect_target(referrer): + return redirect(referrer) + return redirect(url_for('posts.timeline')) diff --git a/usecases/social_media/routes/search.py b/usecases/social_media/routes/search.py new file mode 100644 index 0000000..e85c8c8 --- /dev/null +++ b/usecases/social_media/routes/search.py @@ -0,0 +1,16 @@ +from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from models import user as user_model +from utils.decorators import login_required + +bp = Blueprint('search', __name__) + +@bp.route('/search') +@login_required +def search(): + query = request.args.get('q', '') + results = [] + + if query: + results = user_model.search_users(query) + + return render_template('search.html', query=query, results=results) diff --git a/usecases/social_media/routes/users.py b/usecases/social_media/routes/users.py new file mode 100644 index 0000000..8d7c3cb --- /dev/null +++ b/usecases/social_media/routes/users.py @@ -0,0 +1,55 @@ +from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from models import user as user_model +from models import post as post_model +from utils.decorators import login_required + +bp = Blueprint('users', __name__) + +@bp.route('/profile/') +@login_required +def profile(username): + user = user_model.find_user_by_username(username) + if not user: + flash('User not found', 'error') + return redirect(url_for('posts.timeline')) + + posts = post_model.get_user_posts(user['_id']) + current_user_id = session.get('user_id') + is_own_profile = str(user['_id']) == current_user_id + is_following = user_model.is_following(current_user_id, user['_id']) if not is_own_profile else False + + return render_template('profile.html', + user=user, + posts=posts, + is_own_profile=is_own_profile, + is_following=is_following, + follower_count=len(user.get('followers', [])), + following_count=len(user.get('following', []))) + +@bp.route('/follow/', methods=['POST']) +@login_required +def follow(username): + user = user_model.find_user_by_username(username) + if not user: + flash('User not found', 'error') + return redirect(url_for('posts.timeline')) + + try: + user_model.follow_user(session['user_id'], user['_id']) + flash(f'Now following {username}', 'success') + except ValueError as e: + flash(str(e), 'error') + + return redirect(url_for('users.profile', username=username)) + +@bp.route('/unfollow/', methods=['POST']) +@login_required +def unfollow(username): + user = user_model.find_user_by_username(username) + if not user: + flash('User not found', 'error') + return redirect(url_for('posts.timeline')) + + user_model.unfollow_user(session['user_id'], user['_id']) + flash(f'Unfollowed {username}', 'success') + return redirect(url_for('users.profile', username=username)) diff --git a/usecases/social_media/scripts/seed.py b/usecases/social_media/scripts/seed.py new file mode 100644 index 0000000..31d2728 --- /dev/null +++ b/usecases/social_media/scripts/seed.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Seed the database with sample data and create indexes.""" + +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from models.db import get_db +from models.user import create_user, follow_user +from models.post import create_post + +def create_indexes(): + """Create required database indexes.""" + db = get_db() + + print("Creating indexes...") + + db.users.create_index('username', unique=True) + db.users.create_index('email', unique=True) + + db.posts.create_index([('author_id', 1), ('created_at', -1)]) + db.posts.create_index([('created_at', -1)]) + + print("Indexes created successfully") + +def seed_data(): + """Seed the database with sample users and posts.""" + db = get_db() + + db.users.delete_many({}) + db.posts.delete_many({}) + print("Cleared existing data") + + print("Creating users...") + alice_id = create_user( + username='alice', + email='alice@example.com', + password='password123', + display_name='Alice Johnson', + bio='Software developer and coffee enthusiast' + ) + + bob_id = create_user( + username='bob', + email='bob@example.com', + password='password123', + display_name='Bob Smith', + bio='Tech blogger and open source contributor' + ) + + charlie_id = create_user( + username='charlie', + email='charlie@example.com', + password='password123', + display_name='Charlie Brown', + bio='Designer and creative thinker' + ) + + diana_id = create_user( + username='diana', + email='diana@example.com', + password='password123', + display_name='Diana Prince', + bio='Product manager and tech enthusiast' + ) + + print(f"Created 4 users") + + print("Creating follow relationships...") + follow_user(alice_id, bob_id) + follow_user(alice_id, charlie_id) + follow_user(bob_id, alice_id) + follow_user(bob_id, diana_id) + follow_user(charlie_id, alice_id) + follow_user(charlie_id, bob_id) + follow_user(diana_id, alice_id) + + print("Follow relationships created") + + print("Creating posts...") + posts_data = [ + (alice_id, "Just deployed my first Flask app! So excited to share it with everyone."), + (bob_id, "Working on a new blog post about Python best practices. Stay tuned!"), + (charlie_id, "Design is not just what it looks like. Design is how it works."), + (diana_id, "Product management tip: Always listen to your users."), + (alice_id, "Coffee break! Anyone else need a caffeine boost?"), + (bob_id, "Open source contribution of the day: Fixed a bug in a popular library."), + (charlie_id, "New UI mockups are ready for review. Feedback welcome!"), + (alice_id, "Learning DocumentDB today. The MongoDB compatibility is amazing!"), + (diana_id, "Roadmap planning session went well. Exciting features coming soon!"), + (bob_id, "Published my latest article on microservices architecture."), + (charlie_id, "Typography matters. Small details make a big difference."), + (alice_id, "Debugging is like being a detective in a crime movie where you're also the murderer."), + (diana_id, "User research findings are in. Time to prioritize features!"), + (bob_id, "Code review time. Quality over speed, always."), + (charlie_id, "Color theory and accessibility - why they should always go together."), + ] + + for author_id, content in posts_data: + create_post(author_id, content) + + print(f"Created {len(posts_data)} posts") + +if __name__ == '__main__': + print("Starting database seed...") + create_indexes() + seed_data() + print("\nDatabase seeded successfully!") + print("\nSample users (all with password 'password123'):") + print(" - alice@example.com (@alice)") + print(" - bob@example.com (@bob)") + print(" - charlie@example.com (@charlie)") + print(" - diana@example.com (@diana)") diff --git a/usecases/social_media/static/css/style.css b/usecases/social_media/static/css/style.css new file mode 100644 index 0000000..c3b2abf --- /dev/null +++ b/usecases/social_media/static/css/style.css @@ -0,0 +1,357 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.navbar { + background-color: #1a73e8; + color: white; + padding: 15px 0; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.navbar .container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-brand { + font-size: 24px; + font-weight: bold; +} + +.nav-links { + display: flex; + gap: 20px; +} + +.nav-links a { + color: white; + text-decoration: none; + transition: opacity 0.2s; +} + +.nav-links a:hover { + opacity: 0.8; +} + +.flash-messages { + margin-bottom: 20px; +} + +.flash { + padding: 12px 20px; + border-radius: 4px; + margin-bottom: 10px; +} + +.flash-success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.flash-error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.auth-container { + max-width: 400px; + margin: 50px auto; + background: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.auth-container h1 { + margin-bottom: 20px; + text-align: center; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #1a73e8; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #1a73e8; + color: white; +} + +.btn-primary:hover { + background-color: #1557b0; +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.btn-small { + padding: 5px 10px; + font-size: 12px; +} + +.auth-link { + text-align: center; + margin-top: 20px; +} + +.auth-link a { + color: #1a73e8; + text-decoration: none; +} + +.auth-link a:hover { + text-decoration: underline; +} + +.post-form { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-bottom: 20px; +} + +.post-form h2 { + font-size: 18px; + margin-bottom: 15px; +} + +.post-form textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + resize: vertical; +} + +.posts { + display: flex; + flex-direction: column; + gap: 15px; +} + +.post-card { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.post-header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.post-author { + font-weight: 600; + color: #333; + text-decoration: none; +} + +.post-author:hover { + text-decoration: underline; +} + +.post-date { + color: #666; + font-size: 12px; +} + +.post-content { + margin-bottom: 10px; + white-space: pre-wrap; +} + +.post-actions { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +.no-posts { + text-align: center; + color: #666; + padding: 40px 20px; + background: white; + border-radius: 8px; +} + +.profile-header { + background: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-bottom: 20px; +} + +.profile-header h1 { + margin-bottom: 5px; +} + +.username { + color: #666; + margin-bottom: 15px; +} + +.bio { + margin-bottom: 15px; +} + +.profile-stats { + display: flex; + gap: 20px; + margin-bottom: 15px; +} + +.profile-stats span { + color: #666; +} + +.profile-actions { + margin-top: 15px; +} + +.profile-posts h2 { + margin-bottom: 15px; +} + +.search-form { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-bottom: 20px; +} + +.search-form .form-group { + display: flex; + gap: 10px; +} + +.search-form input { + flex: 1; +} + +.search-results h2 { + margin-bottom: 15px; +} + +.user-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.user-card { + background: white; + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.user-link { + text-decoration: none; + color: #333; + font-size: 16px; +} + +.user-link:hover { + text-decoration: underline; +} + +.user-bio { + margin-top: 5px; + color: #666; + font-size: 14px; +} + +.no-results { + text-align: center; + color: #666; + padding: 40px 20px; + background: white; + border-radius: 8px; +} + +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .navbar .container { + flex-direction: column; + gap: 10px; + } + + .nav-links { + width: 100%; + justify-content: space-around; + } +} diff --git a/usecases/social_media/templates/change_password.html b/usecases/social_media/templates/change_password.html new file mode 100644 index 0000000..88dd3fd --- /dev/null +++ b/usecases/social_media/templates/change_password.html @@ -0,0 +1,26 @@ +{% extends "layout.html" %} + +{% block title %}Change Password - Social Network{% endblock %} + +{% block content %} +
+

Change Password

+
+
+ + +
+
+ + + At least 8 characters with 1 letter and 1 number +
+
+ + +
+ +
+ +
+{% endblock %} diff --git a/usecases/social_media/templates/layout.html b/usecases/social_media/templates/layout.html new file mode 100644 index 0000000..678892a --- /dev/null +++ b/usecases/social_media/templates/layout.html @@ -0,0 +1,38 @@ + + + + + + {% block title %}Social Network{% endblock %} + + + + {% if session.user_id %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/usecases/social_media/templates/login.html b/usecases/social_media/templates/login.html new file mode 100644 index 0000000..ce23f6c --- /dev/null +++ b/usecases/social_media/templates/login.html @@ -0,0 +1,21 @@ +{% extends "layout.html" %} + +{% block title %}Login - Social Network{% endblock %} + +{% block content %} +
+

Login

+
+
+ + +
+
+ + +
+ +
+ +
+{% endblock %} diff --git a/usecases/social_media/templates/profile.html b/usecases/social_media/templates/profile.html new file mode 100644 index 0000000..f5b15f0 --- /dev/null +++ b/usecases/social_media/templates/profile.html @@ -0,0 +1,57 @@ +{% extends "layout.html" %} + +{% block title %}{{ user.display_name }} - Social Network{% endblock %} + +{% block content %} +
+
+

{{ user.display_name }}

+

@{{ user.username }}

+ {% if user.bio %} +

{{ user.bio }}

+ {% endif %} +
+ {{ follower_count }} followers + {{ following_count }} following +
+
+ {% if is_own_profile %} + Change Password + {% else %} + {% if is_following %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% endif %} +
+
+ +
+

Posts

+ {% if posts %} + {% for post in posts %} +
+
+ +
+
{{ post.content }}
+ {% if is_own_profile %} +
+
+ +
+
+ {% endif %} +
+ {% endfor %} + {% else %} +

No posts yet.

+ {% endif %} +
+
+{% endblock %} diff --git a/usecases/social_media/templates/register.html b/usecases/social_media/templates/register.html new file mode 100644 index 0000000..9c878f8 --- /dev/null +++ b/usecases/social_media/templates/register.html @@ -0,0 +1,33 @@ +{% extends "layout.html" %} + +{% block title %}Register - Social Network{% endblock %} + +{% block content %} +
+

Register

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+{% endblock %} diff --git a/usecases/social_media/templates/search.html b/usecases/social_media/templates/search.html new file mode 100644 index 0000000..5427078 --- /dev/null +++ b/usecases/social_media/templates/search.html @@ -0,0 +1,38 @@ +{% extends "layout.html" %} + +{% block title %}Search - Social Network{% endblock %} + +{% block content %} +
+

Search Users

+ +
+
+ + +
+
+ + {% if query %} +
+

Results for "{{ query }}"

+ {% if results %} +
+ {% for user in results %} +
+ + {{ user.display_name }} (@{{ user.username }}) + + {% if user.bio %} +

{{ user.bio }}

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No users found matching "{{ query }}"

+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/usecases/social_media/templates/timeline.html b/usecases/social_media/templates/timeline.html new file mode 100644 index 0000000..7766683 --- /dev/null +++ b/usecases/social_media/templates/timeline.html @@ -0,0 +1,44 @@ +{% extends "layout.html" %} + +{% block title %}Timeline - Social Network{% endblock %} + +{% block content %} +
+

Timeline

+ +
+

Create a Post

+
+
+ +
+ +
+
+ +
+ {% if posts %} + {% for post in posts %} +
+
+ + +
+
{{ post.content }}
+ {% if post.author._id|string == session.user_id %} +
+
+ +
+
+ {% endif %} +
+ {% endfor %} + {% else %} +

No posts yet. Follow some users to see their posts!

+ {% endif %} +
+
+{% endblock %} diff --git a/usecases/social_media/utils/__init__.py b/usecases/social_media/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/usecases/social_media/utils/decorators.py b/usecases/social_media/utils/decorators.py new file mode 100644 index 0000000..2544635 --- /dev/null +++ b/usecases/social_media/utils/decorators.py @@ -0,0 +1,11 @@ +from flask import session, flash, redirect, url_for + +def login_required(f): + """Decorator to require login for a route.""" + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + flash('Please login to access this page', 'error') + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + decorated_function.__name__ = f.__name__ + return decorated_function