Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copy to .env and fill in. NEVER commit .env.
# Required in production. In dev, fallbacks apply if unset.

# Flask session secret. Generate: py -c "import secrets; print(secrets.token_hex(32))"
SSL_SECRET_KEY=

# Fernet key for password encryption. Generate:
# py -c "import os, base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
SSL_FERNET_KEY=

# Database URL. Default: sqlite:///ssl_manager.db
# Production example: postgresql+psycopg://user:pass@host:5432/dbname
DATABASE_URL=

# Set to "production" to require SSL_SECRET_KEY at startup.
FLASK_ENV=development
30 changes: 30 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Secrets — NEVER commit
fernet.key
.env
.env.*
!.env.example
*.key
secrets/

# Database files
*.db
instance/

# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
.python-version

# Logs
logs/
*.log

# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
2 changes: 2 additions & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
web: gunicorn 'app:create_app()' --bind 0.0.0.0:$PORT --workers 2 --timeout 60
release: flask db upgrade
Binary file modified README.md
Binary file not shown.
Binary file removed __pycache__/app.cpython-313.pyc
Binary file not shown.
Binary file removed __pycache__/models.cpython-313.pyc
Binary file not shown.
115 changes: 87 additions & 28 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@
import os
from logging.handlers import RotatingFileHandler

from flask import Flask, render_template, redirect, url_for, flash, send_file
from flask import Flask, redirect, url_for, flash
from flask_login import LoginManager
from flask_migrate import Migrate

# Load .env if present (no-op in production where env is set by the platform)
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass

login_manager = LoginManager()
migrate = Migrate()

# ──────────────────────────────────────────────
# Logging setup
Expand All @@ -18,12 +27,10 @@
datefmt='%Y-%m-%d %H:%M:%S'
)

# Console handler
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(formatter)
_console_handler.setLevel(logging.INFO)

# File handler (10 MB × 5 backups)
_file_handler = RotatingFileHandler(
os.path.join(LOG_DIR, 'ssl_manager.log'),
maxBytes=10 * 1024 * 1024,
Expand All @@ -34,38 +41,82 @@
_file_handler.setLevel(logging.INFO)


def _is_production() -> bool:
return os.environ.get('FLASK_ENV') == 'production'


def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = 'ssl-manager-secret-key-2024'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///ssl_manager.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['PROPAGATE_EXCEPTIONS'] = False # don't re-raise in prod

# Attach handlers to the app logger
# ── Secrets / DB config ──
secret_key = os.environ.get('SSL_SECRET_KEY')
if not secret_key:
if _is_production():
raise RuntimeError('SSL_SECRET_KEY must be set in production')
secret_key = 'dev-only-insecure-key-change-me'
app.config['SECRET_KEY'] = secret_key

database_url = os.environ.get('DATABASE_URL', 'sqlite:///ssl_manager.db')
# Heroku/Render style postgres:// → postgresql+psycopg://
if database_url.startswith('postgres://'):
database_url = 'postgresql+psycopg://' + database_url[len('postgres://'):]
elif database_url.startswith('postgresql://'):
database_url = 'postgresql+psycopg://' + database_url[len('postgresql://'):]
app.config['SQLALCHEMY_DATABASE_URI'] = database_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['PROPAGATE_EXCEPTIONS'] = False

# ── Mail config (used by reminders + invites) ──
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'localhost')
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 25))
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'false').lower() == 'true'
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')
app.config['MAIL_DEFAULT_SENDER'] = os.environ.get(
'MAIL_DEFAULT_SENDER', 'noreply@ssl-manager.local'
)

# ── Stripe config ──
app.config['STRIPE_API_KEY'] = os.environ.get('STRIPE_API_KEY', '')
app.config['STRIPE_WEBHOOK_SECRET'] = os.environ.get('STRIPE_WEBHOOK_SECRET', '')
app.config['STRIPE_PRICE_ID_PRO'] = os.environ.get('STRIPE_PRICE_ID_PRO', '')
app.config['APP_BASE_URL'] = os.environ.get(
'APP_BASE_URL', 'http://localhost:5000'
)

# Logger
app.logger.setLevel(logging.INFO)
if not app.logger.handlers:
app.logger.addHandler(_console_handler)
app.logger.addHandler(_file_handler)

app.logger.info('SSL Manager starting up')

# Extensions
from models import db
db.init_app(app)
migrate.init_app(app, db)

login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Пожалуйста, войдите в систему.'
login_manager.login_message_category = 'warning'

from services.mail import init_mail
init_mail(app)

from models import User

@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

# ──────────────────────────────────────────────
# Global error handlers — no crashes in prod
# ──────────────────────────────────────────────
# ── Error handlers ──
@app.errorhandler(403)
def forbidden(e):
app.logger.warning('403 Forbidden: %s', e)
flash('Доступ запрещён (403).', 'danger')
return redirect(url_for('ssl.index'))

@app.errorhandler(404)
def not_found(e):
app.logger.warning('404 Not Found: %s', e)
Expand All @@ -81,14 +132,13 @@ def method_not_allowed(e):
@app.errorhandler(500)
def internal_error(e):
from models import db as _db
_db.session.rollback() # safety rollback on 500
_db.session.rollback()
app.logger.error('500 Internal Server Error: %s', e, exc_info=True)
flash('Внутренняя ошибка сервера. Попробуйте позже (500).', 'danger')
return redirect(url_for('ssl.index'))

@app.errorhandler(Exception)
def unhandled_exception(e):
"""Catch-all: log every unhandled exception and show a friendly warning."""
try:
from models import db as _db
_db.session.rollback()
Expand All @@ -98,34 +148,43 @@ def unhandled_exception(e):
flash(f'Произошла ошибка: {e}', 'danger')
return redirect(url_for('ssl.index'))

# ──────────────────────────────────────────────
# DB download (for Railway backup)
# ──────────────────────────────────────────────
@app.route('/download-secret-db')
def download_db():
db_path = os.path.join(app.instance_path, 'ssl_manager.db')
return send_file(db_path, as_attachment=True, download_name='backup.db')

# ──────────────────────────────────────────────
# Blueprints
# ──────────────────────────────────────────────
# ── Blueprints ──
from routes.auth import auth_bp
from routes.ssl_keys import ssl_bp
from routes.servers import servers_bp
from routes.accesses import accesses_bp
from routes.billing import billing_bp
from routes.admin import admin_bp

app.register_blueprint(auth_bp)
app.register_blueprint(ssl_bp)
app.register_blueprint(servers_bp)
app.register_blueprint(accesses_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(admin_bp)

with app.app_context():
db.create_all()
# In dev with SQLite, bootstrap schema directly. In production, use
# `flask db upgrade` (Alembic) — handled by Migrate above.
if database_url.startswith('sqlite:'):
with app.app_context():
db.create_all()

# Reminders scheduler (skip when running CLI commands / migrations)
if os.environ.get('ENABLE_SCHEDULER', '1') == '1' and not _is_production_cli():
from services.scheduler import start_scheduler
start_scheduler(app)

app.logger.info('All blueprints registered and DB ready')
return app


def _is_production_cli() -> bool:
"""Detect Flask CLI invocations (db migrate, shell, etc.) so we don't
start the scheduler twice."""
import sys
return any(arg in sys.argv for arg in ('db', 'shell', 'routes'))


if __name__ == '__main__':
app = create_app()
app.run(debug=False) # debug=False in prod — exceptions handled above
app.run(debug=False)
1 change: 0 additions & 1 deletion fernet.key

This file was deleted.

34 changes: 25 additions & 9 deletions init_db.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
"""
Run this script once to initialize the database and create the default admin user.
Initialize the database and create a default admin user + organization.
Usage: python init_db.py
Default login: admin / admin123
Default login: admin / admin123 (DEV ONLY — change immediately)

The default admin is also marked is_staff=True so you can access /admin.
"""
import os

from app import create_app
from models import db, User
from models import db, Organization, User

app = create_app()

with app.app_context():
db.create_all()
if not User.query.filter_by(username='admin').first():
user = User(username='admin')
user.set_password('admin123')

if User.query.filter_by(username='admin').first():
print("[INFO] User 'admin' already exists. Skipping.")
else:
org = Organization(name='Default Organization')
db.session.add(org)
db.session.flush()

password = os.environ.get('SSL_ADMIN_PASSWORD') or 'admin123'
user = User(username='admin', role='admin', is_staff=True, org_id=org.id)
user.set_password(password)
db.session.add(user)
db.session.flush()
org.owner_id = user.id
db.session.commit()
print("[OK] User 'admin' created. Password: admin123")
else:
print("[INFO] User 'admin' already exists.")

if password == 'admin123':
print("[OK] User 'admin' created (owner + staff). Password: admin123 (CHANGE THIS)")
else:
print("[OK] User 'admin' created with SSL_ADMIN_PASSWORD from env.")
print("[OK] Database initialized.")
Binary file removed instance/ssl_manager.db
Binary file not shown.
Loading