From c1f26094deb955f55a64cbd48087c323b595debc Mon Sep 17 00:00:00 2001 From: ION606 Date: Tue, 18 Feb 2025 01:27:40 +0000 Subject: [PATCH] Split routes into multiple files (#2) * split into files * attempted path fix * perms fix * added missed functions * fixed circular dependancy * I hate splitting * env fix * path fix --- Dockerfile | 2 +- Makefile | 3 +- app.py | 447 -------------------- app/__init__.py | 30 ++ app/config.py | 47 ++ app/helpers.py | 27 ++ app/models.py | 25 ++ app/routes.py | 293 +++++++++++++ app/scheduler.py | 16 + {static => app/static}/editor.js | 0 {static => app/static}/favicon.ico | Bin {static => app/static}/favicon.png | Bin {static => app/static}/hosting.png | Bin {static => app/static}/styles.css | 0 {templates => app/templates}/404.html | 8 +- {templates => app/templates}/base.html | 10 +- {templates => app/templates}/dashboard.html | 10 +- {templates => app/templates}/edit.html | 2 +- {templates => app/templates}/home.html | 2 +- {templates => app/templates}/login.html | 2 +- {templates => app/templates}/register.html | 2 +- app/upload.py | 112 +++++ docker-compose.yml | 5 +- run.py | 11 + 24 files changed, 584 insertions(+), 470 deletions(-) delete mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/helpers.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/scheduler.py rename {static => app/static}/editor.js (100%) rename {static => app/static}/favicon.ico (100%) rename {static => app/static}/favicon.png (100%) rename {static => app/static}/hosting.png (100%) rename {static => app/static}/styles.css (100%) rename {templates => app/templates}/404.html (83%) rename {templates => app/templates}/base.html (89%) rename {templates => app/templates}/dashboard.html (87%) rename {templates => app/templates}/edit.html (94%) rename {templates => app/templates}/home.html (96%) rename {templates => app/templates}/login.html (86%) rename {templates => app/templates}/register.html (86%) create mode 100644 app/upload.py create mode 100644 run.py diff --git a/Dockerfile b/Dockerfile index 67770ee..8803aba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,4 +13,4 @@ COPY . . EXPOSE 5121 -CMD ["python", "app.py"] +CMD ["python", "run.py"] diff --git a/Makefile b/Makefile index 3f0d200..17aec03 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,7 @@ all: run run: - docker compose build - docker compose up -d + docker compose up --build -d stop: docker compose down -v diff --git a/app.py b/app.py deleted file mode 100644 index d830931..0000000 --- a/app.py +++ /dev/null @@ -1,447 +0,0 @@ -from flask import ( - Flask, - render_template, - request, - redirect, - url_for, - send_from_directory, - flash, - abort, - jsonify, - session, -) -from flask_sqlalchemy import SQLAlchemy -from flask_login import ( - LoginManager, - UserMixin, - login_user, - logout_user, - login_required, - current_user, -) -from flask_apscheduler import APScheduler -from werkzeug.security import generate_password_hash, check_password_hash -from werkzeug.exceptions import NotFound -import os -import shutil -from datetime import datetime, timedelta -from waitress import serve -import re -from secrets import token_hex - -app = Flask(__name__) -app.config["SESSION_TYPE"] = "filesystem" -app.config["SESSION_FILE_DIR"] = "/app/instance/flask_session" - -try: - with open("/app/instance/secret.key", "rb") as f: - app.config["SECRET_KEY"] = bytes.hex(f.readline()) -except FileNotFoundError as e: - if not os.path.exists("/app/instance"): - os.mkdir("/app/instance") - - with open("/app/instance/secret.key", "wb") as f: - newKey = token_hex(64) - f.write(bytearray.fromhex(newKey)) - app.config["SECRET_KEY"] = newKey - -PORT = 5121 -app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////app/instance/db.sqlite" -app.config["UPLOAD_FOLDER"] = "sites" -app.config["SERVER_NAME"] = "tinysite.cloud" -app.config["SESSION_COOKIE_DOMAIN"] = ".tinysite.cloud" -app.config["SESSION_COOKIE_NAME"] = "tinysite_session" -app.config["SESSION_COOKIE_HTTPONLY"] = True -app.config["SESSION_COOKIE_SECURE"] = True # If using HTTPS -app.config["SESSION_COOKIE_SAMESITE"] = "Lax" -app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { - "pool_pre_ping": True, - "pool_recycle": 300, -} -db = SQLAlchemy(app) - - -RESERVED_SUBDOMAINS = { - "", - "www", - "api", - "admin", - "support", - "docs", - "blog", - "cdn", - "test", - "dev", - "staging", - "secure", - "mail", - "status", - "gateway", -} - - -# TODO: add specific page redirects here as they're added -def isDefaultRoute(hostname: str): - # exactly the main domain - if hostname == app.config["SERVER_NAME"]: - return True - - # a reserved subdomain - parts = hostname.split(".") - server_parts = app.config["SERVER_NAME"].split(".") - - # the host ends with the server domain - if parts[-len(server_parts) :] != server_parts: - return False - - # subdomain portion - subdomain = ".".join(parts[: -len(server_parts)]) - - # any part of the subdomain is reserved - return ( - any(part in RESERVED_SUBDOMAINS for part in subdomain.split(".")) - or subdomain in RESERVED_SUBDOMAINS - ) - - -# Models -class User(UserMixin, db.Model): - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(100), unique=True) - password_hash = db.Column(db.String(128)) # Renamed for clarity - - def set_password(self, password): - self.password_hash = generate_password_hash(password) - - def check_password(self, password): - return check_password_hash(self.password_hash, password) - - -class Site(db.Model): - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer) - name = db.Column(db.String(100)) - subdomain = db.Column(db.String(100), unique=True) - last_accessed = db.Column(db.DateTime) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - - -@app.context_processor -def inject_subdomain(): - host = request.host - server_parts = app.config["SERVER_NAME"].split(".") - host_parts = host.split(".") - - subdomain = None - if host_parts[-len(server_parts) :] == server_parts: - subdomain_parts = host_parts[: -len(server_parts)] - if subdomain_parts: - subdomain = ".".join(subdomain_parts) - - return { - "SUBDOMAIN": subdomain, - "FULL_DOMAIN": host, - "SERVERNAME": app.config["SERVER_NAME"], - } - - -@app.errorhandler(404) -def page_not_found(_): - host = request.host - server_name = app.config["SERVER_NAME"] - server_parts = server_name.split(".") - host_parts = host.split(".") - show_domain = False - - # Case 1: Direct match of main domain - if host == server_name: - show_domain = True - else: - # Extract potential subdomain - if host_parts[-len(server_parts) :] == server_parts: - subdomain = ".".join(host_parts[: -len(server_parts)]) - - # Case 2: Subdomain doesn't exist and isn't reserved - if subdomain and subdomain not in RESERVED_SUBDOMAINS: - if not Site.query.filter_by(subdomain=subdomain).first(): - show_domain = True - - return ( - render_template( - "404.html", - domain=host if show_domain else None, - is_main_domain=(host == server_name), - ), - 404, - ) - - -# Auth setup -login_manager = LoginManager() -login_manager.init_app(app) -login_manager.login_view = "login" - - -@login_manager.user_loader -def load_user(user_id): - return User.query.get(int(user_id)) - - -# Scheduler for auto-deletion -scheduler = APScheduler() -scheduler.init_app(app) - - -def delete_inactive_sites(): - with app.app_context(): - cutoff = datetime.utcnow() - timedelta(days=30) - sites = Site.query.filter(Site.last_accessed < cutoff).all() - for site in sites: - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(site.user_id), str(site.id) - ) - if os.path.exists(site_dir): - shutil.rmtree(site_dir) - db.session.delete(site) - db.session.commit() - - -if not scheduler.running: - scheduler.start() - scheduler.add_job( - id="delete_job", func=delete_inactive_sites, trigger="interval", days=1 - ) - - -# Routes -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - email = request.form.get("email") - password = request.form.get("password") - user = User.query.filter_by(email=email).first() - - if user and user.check_password(password): - login_user(user) - session.permanent = True # Add this line - return redirect(url_for("dashboard")) - flash("Invalid email or password") - return render_template("login.html") - - -@app.route("/logout") -def logout(): - logout_user() - return redirect(url_for("home")) - - -@app.route("/register", methods=["GET", "POST"]) -def register(): - if request.method == "POST": - email = request.form.get("email") - password = request.form.get("password") - - if User.query.filter_by(email=email).first(): - flash("Email already exists") - return redirect(url_for("register")) - - new_user = User(email=email) - new_user.set_password(password) - db.session.add(new_user) - db.session.commit() - - login_user(new_user) - return redirect(url_for("dashboard")) - return render_template("register.html") - - -@app.route("/dashboard") -@login_required -def dashboard(): - sites = Site.query.filter_by(user_id=current_user.id).all() - return render_template( - "dashboard.html", - sites=sites, - subdomain=request.host.split(".")[0], - hostname=app.config["SERVER_NAME"], - ) - - -@app.route("/upload", methods=["POST"]) -@login_required -def upload_site(): - site_name = request.form.get("name") - subdomain = request.form.get("subdomain").strip().lower() # normalize to lowercase - - # Subdomain validation - if not re.match(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", subdomain): - flash( - "Invalid subdomain. Use lowercase letters, numbers, and hyphens.", "error" - ) - return redirect(url_for("dashboard")) - - if not site_name or not subdomain: - flash("Site name and URL subdomain are required", "error") - return redirect(url_for("dashboard")) - - # Check subdomain availability - if Site.query.filter_by(subdomain=subdomain).first(): - flash("This URL is already taken", "error") - return redirect(url_for("dashboard")) - - # Check if index.html is included - files = request.files.getlist("files") - if not any(file.filename == "index.html" for file in files): - flash("You must include an index.html file", "error") - return redirect(url_for("dashboard")) - - # Create site directory - site = Site(user_id=current_user.id, name=site_name, subdomain=subdomain) - db.session.add(site) - db.session.commit() - - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) - os.makedirs(site_dir, exist_ok=True) - - # Save uploaded files - for file in files: - if file.filename == "": - continue - file.save(os.path.join(site_dir, file.filename)) - - flash("Site created successfully!", "success") - return redirect(url_for("dashboard")) - - -@app.route("/edit/", methods=["GET", "POST"]) -@login_required -def edit_site(site_id): - site = Site.query.get_or_404(site_id) - if site.user_id != current_user.id: - return "Unauthorized", 403 - - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) - - if request.method == "POST": - site.name = request.form.get("name", site.name) - db.session.commit() - - for filename, content in request.form.items(): - if filename.endswith((".html", ".css", ".js")): - filepath = os.path.join(site_dir, filename) - with open(filepath, "w") as f: - f.write(content) - - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return jsonify({"success": True, "message": "Site updated successfully!"}) - - flash("Site updated successfully!", "success") - return redirect(url_for("edit_site", site_id=site.id)) - - files = {} - for file in os.listdir(site_dir): - with open(os.path.join(site_dir, file), "r") as f: - files[file] = f.read() - - return render_template("edit.html", site=site, files=files) - - -@app.route("/delete/", methods=["POST"]) -@login_required -def delete_site(site_id): - site = Site.query.get_or_404(site_id) - if site.user_id != current_user.id: - return "Unauthorized", 403 - - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) - if os.path.exists(site_dir): - shutil.rmtree(site_dir) - - db.session.delete(site) - db.session.commit() - return redirect(url_for("dashboard")) - - -@app.route("/delete_file//", methods=["POST"]) -@login_required -def delete_file(site_id, filename): - # Get the site and verify ownership - site = Site.query.get_or_404(site_id) - if site.user_id != current_user.id: - return "Unauthorized", 403 - - # Build the file path - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) - file_path = os.path.join(site_dir, filename) - - # Delete the file if it exists - if os.path.exists(file_path): - os.remove(file_path) - flash(f"File '{filename}' deleted successfully!", "success") - else: - flash(f"File '{filename}' not found!", "error") - - return redirect(url_for("dashboard")) - - -@app.route("/", subdomain="", defaults={"filename": "index.html"}) -@app.route("/", subdomain="") -def serve_site_content(subdomain, filename): - if isDefaultRoute(request.host): - abort(404) # Reserve default routes for main app - - site = Site.query.filter_by(subdomain=subdomain).first_or_404() - site.last_accessed = datetime.utcnow() - db.session.commit() - - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(site.user_id), str(site.id) - ) - - # Security checks - if ".." in filename or filename.startswith("/") or not os.path.exists(site_dir): - abort(404) - - try: - return send_from_directory(site_dir, filename) - except NotFound: - if "." not in filename: - try: - return send_from_directory(site_dir, f"{filename}.html") - except NotFound: - return send_from_directory(site_dir, "index.html") - abort(404) - - -def list_files(directory): - try: - return os.listdir(directory) - except FileNotFoundError: - return [] - - -@app.context_processor -def inject_utilities(): - return dict(list_files=list_files) - - -@app.route("/") -def home(): - if not isDefaultRoute(request.host): - abort(404) - return render_template("home.html") - - -if __name__ == "__main__": - os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) - with app.app_context(): - db.create_all() - serve(app, host="0.0.0.0", port=PORT) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..4efa1f4 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,30 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from .scheduler import scheduler + +db = SQLAlchemy() +login_manager = LoginManager() + + +def create_app(): + app = Flask(__name__) + + # Configuration + app.config.from_object("app.config.Config") + + # Initialize extensions + db.init_app(app) + login_manager.init_app(app) + + # Initialize scheduler + from .scheduler import init_scheduler + + init_scheduler(app) + + # Register blueprints + from .routes import main_routes + + app.register_blueprint(main_routes) + + return app diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1561f9b --- /dev/null +++ b/app/config.py @@ -0,0 +1,47 @@ +import os +from secrets import token_hex + + +def get_secret_key(): + try: + with open("/app/instance/secret.key", "rb") as f: + return bytes.hex(f.readline()) + except FileNotFoundError as e: + if not os.path.exists("/app/instance"): + os.mkdir("/app/instance") + + with open("/app/instance/secret.key", "wb") as f: + newKey = token_hex(64) + f.write(bytearray.fromhex(newKey)) + return newKey + + +class Config: + SECRET_KEY = get_secret_key() + SQLALCHEMY_DATABASE_URI = "sqlite:////app/instance/db.sqlite" + UPLOAD_FOLDER = "/app/sites" + SERVER_NAME = "tinysite.cloud" + SESSION_COOKIE_DOMAIN = ".tinysite.cloud" + SESSION_COOKIE_NAME = "tinysite_session" + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_SAMESITE = "Lax" + SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 300} + PORT = 5121 + RESERVED_SUBDOMAINS = { + "", + "www", + "api", + "admin", + "support", + "docs", + "blog", + "cdn", + "test", + "dev", + "staging", + "secure", + "mail", + "status", + "gateway", + } diff --git a/app/helpers.py b/app/helpers.py new file mode 100644 index 0000000..222866b --- /dev/null +++ b/app/helpers.py @@ -0,0 +1,27 @@ +from flask import current_app +from datetime import datetime, timedelta +import os +import shutil + + +def delete_inactive_sites(): + from .models import db, Site # lazy import to avoid circular dependency + + with current_app.app_context(): + cutoff = datetime.utcnow() - timedelta(days=30) + sites = Site.query.filter(Site.last_accessed < cutoff).all() + for site in sites: + site_dir = os.path.join( + current_app.config["UPLOAD_FOLDER"], str(site.user_id), str(site.id) + ) + if os.path.exists(site_dir): + shutil.rmtree(site_dir) + db.session.delete(site) + db.session.commit() + + +def list_files(directory): + try: + return os.listdir(directory) + except FileNotFoundError: + return [] diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..8349d0c --- /dev/null +++ b/app/models.py @@ -0,0 +1,25 @@ +from . import db +from datetime import datetime +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash + + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(100), unique=True) + password_hash = db.Column(db.String(128)) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + +class Site(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer) + name = db.Column(db.String(100)) + subdomain = db.Column(db.String(100), unique=True) + last_accessed = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..d0b0bb0 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,293 @@ +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + flash, + session, + send_from_directory, + abort, + jsonify, + current_app, +) +from flask_login import login_required, current_user, login_user, logout_user +from werkzeug.exceptions import NotFound +from datetime import datetime, timedelta +import os +import shutil +from .models import db, User, Site +from .upload import handle_upload +from . import login_manager +from .helpers import list_files +from .config import Config + +main_routes = Blueprint("main", __name__, template_folder="templates") + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + + +@main_routes.app_context_processor +def inject_utilities(): + return dict(list_files=list_files) + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + + +# Context processor +@main_routes.app_context_processor +def inject_subdomain(): + host = request.host + server_parts = current_app.config["SERVER_NAME"].split(".") + host_parts = host.split(".") + + subdomain = None + if host_parts[-len(server_parts) :] == server_parts: + subdomain_parts = host_parts[: -len(server_parts)] + if subdomain_parts: + subdomain = ".".join(subdomain_parts) + + return { + "SUBDOMAIN": subdomain, + "FULL_DOMAIN": host, + "SERVERNAME": current_app.config["SERVER_NAME"], + } + + +# Route definitions +@main_routes.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + email = request.form.get("email") + password = request.form.get("password") + user = User.query.filter_by(email=email).first() + + if user and user.check_password(password): + login_user(user) + session.permanent = True + return redirect(url_for("main.dashboard")) + flash("Invalid email or password") + return render_template("login.html") + + +@main_routes.route("/logout") +def logout(): + logout_user() + return redirect(url_for("main.home")) + + +@main_routes.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + email = request.form.get("email") + password = request.form.get("password") + + if User.query.filter_by(email=email).first(): + flash("Email already exists") + return redirect(url_for("main.register")) + + new_user = User(email=email) + new_user.set_password(password) + db.session.add(new_user) + db.session.commit() + + login_user(new_user) + return redirect(url_for("main.dashboard")) + return render_template("register.html") + + +@main_routes.route("/dashboard") +@login_required +def dashboard(): + sites = Site.query.filter_by(user_id=current_user.id).all() + return render_template( + "dashboard.html", + sites=sites, + subdomain=request.host.split(".")[0], + hostname=current_app.config["SERVER_NAME"], + ) + + +@main_routes.route("/upload", methods=["POST"]) +@login_required +def upload_site(): + site_name = request.form.get("name") + subdomain = request.form.get("subdomain").strip().lower() + files = request.files.getlist("files") + return handle_upload(current_user, site_name, subdomain, files) + + +@main_routes.route("/edit/", methods=["GET", "POST"]) +@login_required +def edit_site(site_id): + site = Site.query.get_or_404(site_id) + if site.user_id != current_user.id: + return "Unauthorized", 403 + + site_dir = os.path.join( + current_app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) + + if request.method == "POST": + site.name = request.form.get("name", site.name) + db.session.commit() + + for filename, content in request.form.items(): + if filename.endswith((".html", ".css", ".js")): + filepath = os.path.join(site_dir, filename) + with open(filepath, "w") as f: + f.write(content) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": True, "message": "Site updated successfully!"}) + + flash("Site updated successfully!", "success") + return redirect(url_for("main.edit_site", site_id=site.id)) + + files = {} + for file in os.listdir(site_dir): + with open(os.path.join(site_dir, file), "r") as f: + files[file] = f.read() + + return render_template("edit.html", site=site, files=files) + + +@main_routes.route("/delete/", methods=["POST"]) +@login_required +def delete_site(site_id): + site = Site.query.get_or_404(site_id) + if site.user_id != current_user.id: + return "Unauthorized", 403 + + site_dir = os.path.join( + current_app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) + if os.path.exists(site_dir): + shutil.rmtree(site_dir) + + db.session.delete(site) + db.session.commit() + return redirect(url_for("main.dashboard")) + + +@main_routes.route("/delete_file//", methods=["POST"]) +@login_required +def delete_file(site_id, filename): + site = Site.query.get_or_404(site_id) + if site.user_id != current_user.id: + return "Unauthorized", 403 + + site_dir = os.path.join( + current_app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) + file_path = os.path.join(site_dir, filename) + + if os.path.exists(file_path): + os.remove(file_path) + flash(f"File '{filename}' deleted successfully!", "success") + else: + flash(f"File '{filename}' not found!", "error") + + return redirect(url_for("main.dashboard")) + + +@main_routes.route( + "/", + subdomain="", + defaults={"subdomain": ""}, +) +@main_routes.route("/", subdomain="") +@main_routes.route("/") +def serve_site_content(filename="index.html", subdomain=""): + if is_default_route(request.host): + abort(404) + + # no subdomain provided via route --> extract from host header + if not subdomain: + server_parts = current_app.config["SERVER_NAME"].split('.') + host_parts = request.host.split('.') + if host_parts[-len(server_parts):] == server_parts and len(host_parts) > len(server_parts): + subdomain = ".".join(host_parts[:-len(server_parts)]) + else: + abort(404) + + site = Site.query.filter_by(subdomain=subdomain).first_or_404() + site.last_accessed = datetime.utcnow() + db.session.commit() + + site_dir = os.path.join( + current_app.config["UPLOAD_FOLDER"], str(site.user_id), str(site.id) + ) + + if ".." in filename or filename.startswith("/") or not os.path.exists(site_dir): + abort(404) + + try: + return send_from_directory(site_dir, filename) + except NotFound: + if "." not in filename: + try: + return send_from_directory(site_dir, f"{filename}.html") + except NotFound: + return send_from_directory(site_dir, "index.html") + abort(404) + + +@main_routes.route("/") +def home(): + if not is_default_route(request.host): + abort(404) + return render_template("home.html") + + +# Helper functions +def is_default_route(hostname: str): + if hostname == current_app.config["SERVER_NAME"]: + return True + + parts = hostname.split(".") + server_parts = current_app.config["SERVER_NAME"].split(".") + + if parts[-len(server_parts) :] != server_parts: + return False + + subdomain = ".".join(parts[: -len(server_parts)]) + return ( + any(part in Config.RESERVED_SUBDOMAINS for part in subdomain.split(".")) + or subdomain in Config.RESERVED_SUBDOMAINS + ) + + +# Error handler (would typically be registered in current_app factory) +@main_routes.app_errorhandler(404) +def page_not_found(_): + print("404", request.host) + host = request.host + server_name = current_app.config["SERVER_NAME"] + server_parts = server_name.split(".") + host_parts = host.split(".") + show_domain = False + + if host == server_name: + show_domain = True + else: + if host_parts[-len(server_parts) :] == server_parts: + subdomain = ".".join(host_parts[: -len(server_parts)]) + if subdomain and subdomain not in Config.RESERVED_SUBDOMAINS: + if not Site.query.filter_by(subdomain=subdomain).first(): + show_domain = True + + return ( + render_template( + "404.html", + domain=host if show_domain else None, + is_main_domain=(host == server_name), + ), + 404, + ) diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..9e28b10 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,16 @@ +# app/scheduler.py +from flask_apscheduler import APScheduler +from .helpers import delete_inactive_sites + +scheduler = APScheduler() + + +def init_scheduler(app): + scheduler.init_app(app) + scheduler.start() + scheduler.add_job( + id="delete_job", + func=delete_inactive_sites, + trigger="interval", + days=1, + ) diff --git a/static/editor.js b/app/static/editor.js similarity index 100% rename from static/editor.js rename to app/static/editor.js diff --git a/static/favicon.ico b/app/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to app/static/favicon.ico diff --git a/static/favicon.png b/app/static/favicon.png similarity index 100% rename from static/favicon.png rename to app/static/favicon.png diff --git a/static/hosting.png b/app/static/hosting.png similarity index 100% rename from static/hosting.png rename to app/static/hosting.png diff --git a/static/styles.css b/app/static/styles.css similarity index 100% rename from static/styles.css rename to app/static/styles.css diff --git a/templates/404.html b/app/templates/404.html similarity index 83% rename from templates/404.html rename to app/templates/404.html index f70ac7f..19c4a33 100644 --- a/templates/404.html +++ b/app/templates/404.html @@ -66,14 +66,14 @@ {% if current_user.is_authenticated %} {% if domain %} - + 🚀 Create {{ domain.split('.')[0]|default("Site", true) }} {% else %}
{% if is_main_domain %}

Start your web hosting journey today!

- Sign Up + Sign Up {% else %}

The page you requested doesn't exist on this site.

{% endif %} @@ -82,8 +82,8 @@ {% else %}

Start your web hosting journey today!

- Login - Sign Up + Login + Sign Up
{% endif %} diff --git a/templates/base.html b/app/templates/base.html similarity index 89% rename from templates/base.html rename to app/templates/base.html index 27318ea..fd73dcf 100644 --- a/templates/base.html +++ b/app/templates/base.html @@ -52,13 +52,13 @@
diff --git a/templates/login.html b/app/templates/login.html similarity index 86% rename from templates/login.html rename to app/templates/login.html index 3fd223d..25c4845 100644 --- a/templates/login.html +++ b/app/templates/login.html @@ -4,7 +4,7 @@ {% block content %}

Login

-
+
diff --git a/templates/register.html b/app/templates/register.html similarity index 86% rename from templates/register.html rename to app/templates/register.html index ab1250e..1852197 100644 --- a/templates/register.html +++ b/app/templates/register.html @@ -4,7 +4,7 @@ {% block content %}

Register

- +
diff --git a/app/upload.py b/app/upload.py new file mode 100644 index 0000000..9249c8b --- /dev/null +++ b/app/upload.py @@ -0,0 +1,112 @@ +# upload_handler.py +from flask import flash, redirect, url_for, current_app +import os +import re +from .config import Config +from .models import Site +from . import db + +# Constants +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB per file +MAX_TOTAL_STORAGE = 100 * 1024 * 1024 # 100MB per user +ALLOWED_EXTENSIONS = { + "html", + "css", + "js", + "png", + "jpg", + "jpeg", + "gif", + "svg", + "ico", + "webp", + "txt", + "pdf", +} +MAX_FILES_PER_UPLOAD = 50 + + +def get_user_storage(user_id): + total_size = 0 + user_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], str(user_id)) + if os.path.exists(user_dir): + for site_dir in os.listdir(user_dir): + site_path = os.path.join(user_dir, site_dir) + if os.path.isdir(site_path): + for root, _, files in os.walk(site_path): + for f in files: + fp = os.path.join(root, f) + total_size += os.path.getsize(fp) + return total_size + + +def handle_upload(current_user, site_name, subdomain, files): + # Validation checks + if not re.match(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", subdomain): + flash("Invalid subdomain format", "error") + return redirect(url_for("main.dashboard")) + + if not site_name or not subdomain: + flash("Name and subdomain required", "error") + return redirect(url_for("main.dashboard")) + + if Site.query.filter_by(subdomain=subdomain).first(): + flash("Subdomain taken", "error") + return redirect(url_for("main.dashboard")) + + if len(files) > MAX_FILES_PER_UPLOAD: + flash(f"Max {MAX_FILES_PER_UPLOAD} files per upload", "error") + return redirect(url_for("main.dashboard")) + + for file in files: + if "." not in file.filename: + flash(f"File {file.filename} has no extension", "error") + return redirect(url_for("main.dashboard")) + ext = file.filename.rsplit(".", 1)[1].lower() + if ext not in ALLOWED_EXTENSIONS: + flash(f".{ext} files not allowed", "error") + return redirect(url_for("main.dashboard")) + + total_new_size = 0 + for file in files: + file.stream.seek(0, os.SEEK_END) + size = file.stream.tell() + file.stream.seek(0) + if size > MAX_FILE_SIZE: + flash(f"{file.filename} exceeds {MAX_FILE_SIZE//1024//1024}MB", "error") + return redirect(url_for("main.dashboard")) + total_new_size += size + + current_storage = get_user_storage(current_user.id) + if current_storage + total_new_size > MAX_TOTAL_STORAGE: + flash(f"Storage limit ({MAX_TOTAL_STORAGE//1024//1024}MB) exceeded", "error") + return redirect(url_for("main.dashboard")) + + if not any(f.filename == "index.html" for f in files): + flash("Missing index.html", "error") + return redirect(url_for("main.dashboard")) + + # Create site + site = Site(user_id=current_user.id, name=site_name, subdomain=subdomain) + db.session.add(site) + db.session.commit() + + # Save files + site_dir = os.path.join( + current_app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) + os.makedirs(site_dir, exist_ok=True) + + try: + for file in files: + if file.filename: + file.save(os.path.join(site_dir, file.filename)) + except Exception as e: + db.session.delete(site) + db.session.commit() + flash("Upload failed", "error") + return redirect(url_for("main.dashboard")) + + flash("Site created!", "success") + return redirect(url_for("main.dashboard")) + diff --git a/docker-compose.yml b/docker-compose.yml index bb1721f..b19c913 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - static volumes: - instance:/app/instance - - ./sites:/app/sites + - sites:/app/sites environment: - FLASK_ENV=production @@ -15,4 +15,5 @@ networks: static: volumes: - instance: \ No newline at end of file + instance: + sites: \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..51e67c1 --- /dev/null +++ b/run.py @@ -0,0 +1,11 @@ +from waitress import serve +import os +from app import create_app, db, config + +app = create_app() + +if __name__ == "__main__": + os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) + with app.app_context(): + db.create_all() + serve(app, host="0.0.0.0", port=config.Config.PORT)