init commit

This commit is contained in:
2025-10-30 07:58:22 -05:00
commit 8405edd191
32 changed files with 1210 additions and 0 deletions

61
app/__init__.py Normal file
View File

@@ -0,0 +1,61 @@
import os
from flask import Flask
from dotenv import load_dotenv
from .utils.extensions import login_manager
from .blueprints.auth.routes import auth_bp
from .blueprints.main.routes import main_bp
from flask import g
from flask_login import current_user
from .utils.tokens import ensure_fresh_appwrite_jwt
# load_dotenv()
from .utils.settings import get_settings
settings = get_settings()
def create_app():
app = Flask(__name__, template_folder="templates")
app.config.update(
SECRET_KEY=settings.flask_secret_key,
APPWRITE_ENDPOINT=settings.appwrite_endpoint,
APPWRITE_PROJECT_ID=settings.appwrite_project_id,
APPWRITE_API_KEY=settings.appwrite_api_key,
SESSION_COOKIE_SECURE = False,
SESSION_COOKIE_SAMESITE = "Lax",
REMEMBER_COOKIE_SAMESITE = "Lax",
REMEMBER_COOKIE_SECURE = False
)
if not app.config["APPWRITE_ENDPOINT"] or not app.config["APPWRITE_PROJECT_ID"]:
raise RuntimeError("Missing APPWRITE_ENDPOINT or APPWRITE_PROJECT_ID")
# Extensions
login_manager.init_app(app)
login_manager.login_view = "auth.login"
# Blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@app.before_request
def _refresh_jwt_if_needed():
# Only when logged in; ignore static files etc.
if getattr(current_user, "is_authenticated", False):
try:
# mint if near expiry; otherwise no-op
g.appwrite_jwt = ensure_fresh_appwrite_jwt()
except Exception:
# If the Appwrite session is gone, we don't crash the page;
# your protected routes will redirect to login as usual.
pass
@app.context_processor
def inject_globals():
"""Add variables available to all Jinja templates."""
return dict(
app_name=settings.app_name,
app_version=settings.app_version
)
return app

View File

@@ -0,0 +1,158 @@
# app/auth/routes.py
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_user, logout_user, login_required
from app.utils.extensions import User, get_client_from_session
from app.services.appwrite_client import AppwriteAccountClient
auth_bp = Blueprint("auth", __name__, url_prefix="")
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
email = request.form.get("email", "").strip()
password = request.form.get("password", "")
name = (request.form.get("name") or "").strip() or None
if not email or not password:
flash("Email and password are required.", "error")
return redirect(url_for("auth.register"))
try:
# Create user (no bound session needed)
aw = AppwriteAccountClient(use_admin=True)
aw.create_user(email=email, password=password, name=name)
# Create a session and STORE THE SECRET in Flask session
sess_obj = aw.create_email_password_session(email=email, password=password)
secret = sess_obj.get("secret")
session["appwrite_cookies"] = secret
# Bind and fetch profile (either reuse aw with binding or make a new instance)
aw = AppwriteAccountClient(cookies=secret)
profile = aw.get_account()
session["user_profile"] = profile
# (Optional) Create a JWT for short-lived API calls
jwt = aw.create_jwt()
session["appwrite_jwt"] = jwt
login_user(User(id=profile["$id"], email=profile["email"], name=profile.get("name")))
flash("Account created and you are now logged in.", "success")
return redirect(url_for("main.dashboard"))
except Exception as e:
flash(str(e), "error")
return redirect(url_for("auth.register"))
return render_template("auth/register.html")
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
email = request.form.get("email", "").strip()
password = request.form.get("password", "")
if not email or not password:
flash("Email and password are required.", "error")
return redirect(url_for("auth.login"))
try:
# 1) Create session once
aw = AppwriteAccountClient(use_admin=True)
sess_obj = aw.create_email_password_session(email=email, password=password)
secret = sess_obj.get("secret")
# 2) Save the secret for SSR calls
session["appwrite_cookies"] = secret
# 3) Bind and load profile
aw = AppwriteAccountClient(cookies=secret)
profile = aw.get_account()
session["user_profile"] = profile
# optional: create a JWT now if your dashboard expects it
try:
session["appwrite_jwt"] = aw.create_jwt()
except Exception:
session["appwrite_jwt"] = {}
usersname = profile.get("name")
login_user(User(id=profile["$id"], email=profile["email"], name=profile.get("name")))
flash(f"Welcome back {usersname}!", "success")
return redirect(url_for("main.dashboard"))
except Exception as e:
flash(str(e), "error")
return redirect(url_for("auth.login"))
# Get method
return render_template("auth/login.html")
@auth_bp.route("/logout", methods=["GET", "POST"])
@login_required
def logout():
secret = session.get("appwrite_cookies")
if secret:
try:
aw = AppwriteAccountClient(cookies=secret)
aw.logout_current() # best effort
except Exception:
pass
session.clear()
logout_user()
flash("Signed out.", "success")
return redirect(url_for("auth.login"))
@auth_bp.route("/send", methods=["POST"])
@login_required
def send():
"""
Sends a verification email to the currently logged-in user.
Appwrite will redirect the user back to /verify/callback with userId & secret.
"""
try:
aw = get_client_from_session()
callback = url_for("auth.callback", _external=True) # Must be allowed in Appwrite -> Platforms
aw.send_verification(callback_url=callback)
flash("Verification email sent. Please check your inbox.", "info")
except Exception as e:
flash(f"Could not send verification email: {e}", "error")
# Go back to where the user came from, or your dashboard
return redirect(request.referrer or url_for("main.dashboard"))
@auth_bp.route("/callback", methods=["GET"])
def callback():
"""
Completes verification after user clicks the email link.
Requires the user to be logged in (Appwrite Account endpoints need a session).
If not logged in, we stash the link params and send them to log in first.
"""
user_id = request.args.get("userId")
secret = request.args.get("secret")
if not user_id or not secret:
flash("Invalid verification link.", "error")
return redirect(url_for("auth.login"))
# If we don't currently have an Appwrite session, ask them to log in, then resume.
if not session.get("appwrite_cookies"):
session["pending_verification"] = {"userId": user_id, "secret": secret}
flash("Please log in to complete email verification.", "warning")
return redirect(url_for("auth.login"))
# We have a session; complete verification
try:
aw = get_client_from_session()
aw.complete_verification(user_id=user_id, secret=secret)
# Refresh cached profile so templates reflect emailVerification=True
profile = aw.get_account()
session["user_profile"] = profile
# Cleanup any pending state
session.pop("pending_verification", None)
flash("Email verified! You're all set.", "success")
return redirect(url_for("main.dashboard"))
except Exception as e:
flash(f"Verification failed: {e}", "error")
return redirect(url_for("auth.login"))

View File

@@ -0,0 +1,18 @@
from flask import Blueprint, redirect, url_for, render_template, session,flash
from flask_login import login_required, current_user
main_bp = Blueprint("main", __name__, url_prefix="")
@main_bp.route("/")
def home():
if current_user.is_authenticated:
return redirect(url_for("main.dashboard"))
return redirect(url_for("auth.login"))
@main_bp.route("/dashboard")
@login_required
def dashboard():
jwt_info = session.get("appwrite_jwt", {})
profile = session.get("user_profile", {})
return render_template("main/dashboard.html", profile=profile, jwt_info=jwt_info)

View File

@@ -0,0 +1,84 @@
# app/services/appwrite_client.py
from __future__ import annotations
import os
from typing import Optional, Dict, Any, Mapping, Union
from flask import current_app, has_request_context, request, session
from appwrite.client import Client
from appwrite.services.account import Account
from appwrite.id import ID
class AppwriteAccountClient:
def __init__(self, cookies: Optional[Union[str, Mapping[str, str]]] = None, use_admin: bool = False) -> None:
endpoint = current_app.config.get("APPWRITE_ENDPOINT") or os.getenv("APPWRITE_ENDPOINT")
project_id = current_app.config.get("APPWRITE_PROJECT_ID") or os.getenv("APPWRITE_PROJECT_ID")
api_key = current_app.config.get("APPWRITE_API_KEY") or os.getenv("APPWRITE_API_KEY")
if not endpoint or not project_id:
raise RuntimeError("APPWRITE_ENDPOINT and APPWRITE_PROJECT_ID must be configured")
self.endpoint = endpoint
self.project_id = project_id
self.client = Client()
self.client.set_endpoint(self.endpoint)
self.client.set_project(self.project_id)
# If we need admin privileges (to get session.secret), set the API key
if use_admin:
if not api_key:
raise RuntimeError("APPWRITE_API_KEY is required when use_admin=True")
self.client.set_key(api_key)
# Bind session if available (explicit → browser cookie → Flask session)
bound = False
if cookies:
bound = self._bind_session_from(cookies)
if not bound and has_request_context():
bound = self._bind_session_from(request.cookies)
if not bound and has_request_context():
secret = session.get("appwrite_cookies")
if secret:
self.client.set_session(secret)
bound = True
self.account = Account(self.client)
@staticmethod
def session_cookie_key(project_id: str) -> str:
return f"a_session_{project_id}"
def _bind_session_from(self, cookies: Union[str, Mapping[str, str]]) -> bool:
if isinstance(cookies, str):
self.client.set_session(cookies); return True
key = f"a_session_{self.project_id}"
if key in cookies and cookies.get(key):
self.client.set_session(cookies[key]); return True
for v in cookies.values():
if v: self.client.set_session(v); return True
return False
# --- Auth & account helpers ---
def create_user(self, email: str, password: str, name: Optional[str] = None, user_id: Optional[str] = None) -> Dict[str, Any]:
return dict(self.account.create(user_id=user_id or ID.unique(), email=email, password=password, name=name))
def create_email_password_session(self, email: str, password: str) -> Dict[str, Any]:
return dict(self.account.create_email_password_session(email=email, password=password))
def create_jwt(self) -> Dict[str, Any]:
return dict(self.account.create_jwt())
def get_account(self) -> Dict[str, Any]:
return dict(self.account.get())
def logout_current(self) -> bool:
self.account.delete_session("current")
return True
# --- Email verification ---
def send_verification(self, callback_url: str) -> Dict[str, Any]:
return dict(self.account.create_verification(url=callback_url))
def complete_verification(self, user_id: str, secret: str) -> Dict[str, Any]:
return dict(self.account.update_verification(user_id=user_id, secret=secret))

22
app/static/css/halfmoon.min.css vendored Normal file

File diff suppressed because one or more lines are too long

6
app/static/css/style.css Normal file
View File

@@ -0,0 +1,6 @@
html {
font-size: 90%;
}
body {
font-family: "Martian Mono", monospace !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,35 @@
{% set bs_class = {
'success': 'bg-success text-white',
'error': 'bg-danger text-white',
'warning': 'bg-warning text-dark',
'info': 'bg-info text-dark'
} %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1100;">
{% for category, message in messages %}
<div class="toast align-items-center {{ bs_class.get(category, 'bg-secondary text-white') }}" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<strong class="me-2">{{ category|capitalize }}:</strong> {{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
</div>
<script>
// Show all toasts on page load
document.addEventListener("DOMContentLoaded", function () {
var toastElList = [].slice.call(document.querySelectorAll('.toast'));
toastElList.forEach(function (toastEl) {
// Autohide after 5s; tweak as needed
var t = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 });
t.show();
});
});
</script>
{% endif %}
{% endwith %}

View File

@@ -0,0 +1,28 @@
{% extends "bases/login_base.html" %}
{% block content %}
<div class="card shadow-lg p-4" style="max-width: 400px; width: 100%;">
<div class="card-body text-center">
<h5 class="card-title mb-4">
<i class="fa-solid fa-right-to-bracket me-2"></i> Login to Code of Conquest
</h5>
<form method="post" class="text-start">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" name="email" type="email" class="form-control" required placeholder="you@example.com" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" class="form-control" required />
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</div>
</form>
</div>
<div class="card-footer text-center">
<small>No account?</small>
<a href="{{ url_for('auth.register') }}" class="btn btn-link p-0 ms-1">Create one</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "bases/login_base.html" %}
{% block content %}
<div class="card shadow-lg p-4" style="max-width: 400px; width: 100%;">
<div class="card-body text-center">
<h5 class="card-title mb-4">
<i class="fa-solid fa-user-plus me-2"></i> Create Your Account
</h5>
<form method="post" class="text-start">
<div class="mb-3">
<label for="name" class="form-label">Name <span class="text-muted">(optional)</span></label>
<input id="name" name="name" type="text" class="form-control" placeholder="Jane Doe">
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" name="email" type="email" class="form-control" required placeholder="you@example.com">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" class="form-control" required>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-success w-100">Sign up</button>
</div>
</form>
</div>
<div class="card-footer text-center">
<small>Already have an account?</small>
<a href="{{ url_for('auth.login') }}" class="btn btn-link p-0 ms-1">Sign in</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<title>{{ title or "Code of Conquest" }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Halfmoon CSS -->
<link href="https://cdn.jsdelivr.net/npm/halfmoon@2.0.2/css/halfmoon.min.css" rel="stylesheet" integrity="sha256-RjeFzczeuZHCyS+Gvz+kleETzBF/o84ZRHukze/yv6o=" crossorigin="anonymous">
<!-- Google Font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Martian+Mono:wght@300;400;500;700&display=swap">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="d-flex flex-column justify-content-center align-items-center vh-100">
<!-- Centered content block -->
<main class="w-100 d-flex justify-content-center align-items-center flex-grow-1">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="text-center mt-auto mb-3 small text-muted">
<p class="mb-0">&copy; 2025 <a href="/" class="text-reset text-decoration-none">Code of Conquest</a></p>
<p class="mb-0"><span class="version">v 0.1.0</span></p>
</footer>
<!-- App Write JS Client -->
<script src="https://cdn.jsdelivr.net/npm/appwrite@17.0.0"></script>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha256-CDOy6cOibCWEdsRiZuaHf8dSGGJRYuBGC+mjoJimHGw=" crossorigin="anonymous"></script>
{% include "_flash_sticky.html" %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark" data-bs-core="elegant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel{% endblock %} - {{app_version}}</title>
<link rel="icon" href="{{ url_for('static', filename='images/favicons/favicon-32x32.png') }}" type="image/png">
<!-- Halfmoon CSS -->
<link href="https://cdn.jsdelivr.net/npm/halfmoon@2.0.2/css/halfmoon.min.css" rel="stylesheet" integrity="sha256-RjeFzczeuZHCyS+Gvz+kleETzBF/o84ZRHukze/yv6o=" crossorigin="anonymous">
<!-- Google Font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Martian+Mono:wght@300;400;500;700&display=swap">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="ps-xxl-sbwidth">
<!-- Top navbar (mobile) -->
<nav class="navbar navbar-expand-xxl bg-body-tertiary border-bottom border-opacity-25 d-flex d-xxl-none">
<div class="container-fluid">
<a href="{{ url_for('main.dashboard') }}" class="navbar-brand">
<img src="{{ url_for('static', filename='images/COC_Icon.png') }}" alt="icon" width="24" height="24"> Admin Panel
</a>
<button class="btn btn-secondary" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebar">
<i class="fa-solid fa-bars"></i>
</button>
</div>
</nav>
{% include "bases/side_nav.html" %}
{% if current_user.is_authenticated and not current_user.email_verification %}
<div class="alert alert-warning" role="alert">
Please verify your email to unlock all features.
<form method="post" action="{{ url_for('auth.send') }}" style="display:inline">
<button class="btn btn-sm btn-primary">Resend verification email</button>
</form>
</div>
{% endif %}
{% block body %}
<!-- Page content goes here -->
{% endblock %}
<!-- Footer -->
<footer class="text-center mt-auto mb-3 small text-muted">
<p class="mb-0">&copy; 2025 <a href="/" class="text-reset text-decoration-none">Code of Conquest</a></p>
<p class="mb-0"><span class="version">{{app_version}}</span></p>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha256-CDOy6cOibCWEdsRiZuaHf8dSGGJRYuBGC+mjoJimHGw=" crossorigin="anonymous"></script>
<!-- <script src="/static/halfmoon/halfmoon-1.1.1.min.js"></script> -->
{% include "_flash_sticky.html" %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,40 @@
{# Helper to mark active links by endpoint name #}
{% macro nav_link(endpoint, text) -%}
{%- set href = url_for(endpoint) -%}
{%- set active = 'active' if request.endpoint == endpoint else '' -%}
<a href="{{ href }}" class="nav-link {{ active }}" {% if active %}aria-current="page"{% endif %}>{{ text }}</a>
{%- endmacro %}
<nav class="sidebar offcanvas-start offcanvas-xxl" id="sidebar" tabindex="-1">
<div class="offcanvas-header bg-body-tertiary border-bottom border-opacity-25">
<a class="sidebar-brand" href="{{ url_for('main.dashboard') }}">
<img src="{{ url_for('static', filename='images/COC_Icon.png') }}" alt="icon" width="32" height="32"> Admin Panel
</a>
<small>
<span class="version">{{ version or '' }}</span>
</small>
</div>
<div class="offcanvas-body">
<ul class="sidebar-nav">
<li><h6 class="sidebar-header text-uppercase">Dashboard</h6></li>
<li><hr class="sidebar-divider"></li>
<li class="nav-item">
{{ nav_link('main.dashboard', 'Home') }}
</li>
<li class="nav-item">
{# nav_link('profile', 'Profile') #}
</li>
<li class="nav-item">
{# nav_link('login', 'Login') #}
</li>
<li class="nav-item">
{{ nav_link('auth.logout', 'Logout') }}
</li>
</ul>
</div>
</nav>

View File

@@ -0,0 +1,19 @@
{% extends "bases/main_base.html" %}
{% block title %}Admin Panel — Dashboard{% endblock %}
{% block body %}
<div class="container-fluid vh-100">
<div class="row justify-content-center align-items-center vh-100">
<div class="col-12">
<div class="text-center">
<h1 class="mb-0">
Code of Conquest Dashboard
</h1>
<p class="mb-0"></p>
<img src="{{ url_for('static', filename='images/COC_Logo.png') }}" alt="logo" width="300" height="300">
</div>
</div>
</div>
</div>
{% endblock %}

56
app/utils/extensions.py Normal file
View File

@@ -0,0 +1,56 @@
# app/utils/extensions.py
from flask_login import LoginManager
from flask import session
from dataclasses import dataclass
from typing import Optional
from app.services.appwrite_client import AppwriteAccountClient
login_manager = LoginManager()
@dataclass
class User:
id: str
email: str
name: Optional[str] = None
email_verification: bool = False
def is_active(self): return True
def is_authenticated(self): return True
def is_anonymous(self): return False
def get_id(self): return self.id
@login_manager.user_loader
def load_user(user_id: str) -> Optional[User]:
# First: use cached profile
u = session.get("user_profile")
if u and u.get("$id") == user_id:
return User(
id=u["$id"],
email=u["email"],
name=u.get("name"),
email_verification=bool(u.get("emailVerification", False)),
)
# Next: use the session secret we stored at login
secret = session.get("appwrite_cookies")
if not secret:
return None
aw = AppwriteAccountClient(cookies=secret)
try:
acc = aw.get_account()
session["user_profile"] = acc
return User(
id=acc["$id"],
email=acc["email"],
name=acc.get("name"),
email_verification=bool(acc.get("emailVerification", False)),
)
except Exception:
return None
def get_client_from_session() -> AppwriteAccountClient:
secret = session.get("appwrite_cookies")
if not secret:
raise RuntimeError("No Appwrite session is available. Please log in.")
return AppwriteAccountClient(cookies=secret)

149
app/utils/logging.py Normal file
View File

@@ -0,0 +1,149 @@
"""
Structured logging setup for Code of Conquest.
Environment-aware behavior:
- dev → colorful console output (human-friendly)
- test/prod → JSON logs (machine-friendly)
Features:
- Includes logger name, level, filename, and line number
- Unified stdlib + structlog integration
- Respects LOG_LEVEL from environment
- Works with both ChatGPT/Ollama components and Web/TUI layers
"""
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass
from typing import Optional
import structlog
from structlog.dev import ConsoleRenderer
from structlog.processors import JSONRenderer, TimeStamper, CallsiteParameterAdder, CallsiteParameter
from structlog.stdlib import ProcessorFormatter
@dataclass
class _ConfiguredState:
configured: bool = False
level_name: str = "INFO"
_state = _ConfiguredState()
def _level_from_name(name: str) -> int:
mapping = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
"NOTSET": logging.NOTSET,
}
return mapping.get((name or "INFO").upper(), logging.INFO)
def _shared_processors():
"""
Processors common to both console and JSON pipelines.
Adds level, logger name, and callsite metadata.
"""
return [
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
CallsiteParameterAdder(
parameters=[
CallsiteParameter.FILENAME,
CallsiteParameter.LINENO,
CallsiteParameter.FUNC_NAME,
]
),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
def _console_renderer():
"""Pretty colored logs for development."""
return ConsoleRenderer()
def _json_renderer():
"""Machine-friendly JSON logs for test/prod."""
return JSONRenderer(sort_keys=True)
def configure_logging(settings=None) -> None:
"""
Configure structlog + stdlib logging once for the process.
"""
if _state.configured:
return
if settings is None:
from app.core.utils.settings import get_settings # lazy import
settings = get_settings()
env = settings.env.value
level_name = settings.log_level or "INFO"
level = _level_from_name(level_name)
# Choose renderers
if env == "dev":
renderer = _console_renderer()
foreign_pre_chain = _shared_processors()
else:
renderer = _json_renderer()
foreign_pre_chain = _shared_processors() + [
TimeStamper(fmt="iso", utc=True, key="ts")
]
# stdlib -> structlog bridge
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
handler.setFormatter(
ProcessorFormatter(
processor=renderer,
foreign_pre_chain=foreign_pre_chain,
)
)
root = logging.getLogger()
root.handlers.clear()
root.setLevel(level)
root.addHandler(handler)
# Quiet noisy libs
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("uvicorn").setLevel(logging.INFO)
# structlog pipeline
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
*foreign_pre_chain,
ProcessorFormatter.wrap_for_formatter, # hand off to renderer
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
_state.configured = True
_state.level_name = level_name
def get_logger(name: Optional[str] = None) -> structlog.stdlib.BoundLogger:
"""
Retrieve a structlog logger.
"""
if name is None:
return structlog.get_logger()
return structlog.get_logger(name)

129
app/utils/settings.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Environment-aware settings for Code of Conquest.
- Loads environment variables from OS and `.env` (OS wins).
- Provides repo-relative default paths for data storage.
- Validates a few key fields (env, model backend).
- Ensures important directories exist on first load.
- Exposes a tiny singleton: get_settings().
Style:
- Python 3.11+
- Dataclasses (no Pydantic)
- Docstrings + inline comments
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
class Environment(str, Enum):
DEV = "dev"
TEST = "test"
PROD = "prod"
def _repo_root_from_here() -> Path:
"""
Resolve the repository root by walking up from this file.
This file lives at: project/app/core/utils/settings.py
So parents[3] should be the repo root:
parents[0] = utils
parents[1] = core
parents[2] = app
parents[3] = project root
"""
here = Path(__file__).resolve()
repo_root = here.parents[3]
return repo_root
@dataclass
class Settings:
"""
Settings container for Code of Conquest.
Load order:
1) OS environment
2) .env file (at repo root)
3) Defaults below
Paths default into the repo under ./data unless overridden.
"""
# --- Core Tunables---
env: Environment = Environment.DEV
log_level: str = "INFO"
flask_secret_key: str = os.getenv("FLASK_SECRET_KEY","change-me-for-prod")
# APPWRITE Things
appwrite_endpoint: str = os.getenv("APPWRITE_ENDPOINT","NOT SET")
appwrite_project_id: str = os.getenv("APPWRITE_PROJECT_ID","NOT SET")
appwrite_api_key: str = os.getenv("APPWRITE_API_KEY","NOT SET")
app_name: str = "Code of Conquest"
app_version: str = "v 0.0.1"
# --- Paths (default under ./data) ---
repo_root: Path = field(default_factory=_repo_root_from_here)
# --- Build paths for convenience (not env-controlled directly) ---
def __post_init__(self) -> None:
# Basic validation
if self.env not in (Environment.DEV, Environment.TEST, Environment.PROD):
raise ValueError(f"Invalid COC_ENV: {self.env}")
@staticmethod
def _ensure_dir(path: Path) -> None:
if path is None:
return
if not path.exists():
path.mkdir(parents=True, exist_ok=True)
# ---- Singleton loader ----
_settings_singleton: Optional[Settings] = None
def get_settings() -> Settings:
"""
Load settings from environment and `.env` once, then reuse.
OS env always takes precedence over `.env`.
Returns:
Settings: A process-wide singleton instance.
"""
global _settings_singleton
if _settings_singleton is not None:
return _settings_singleton
# Load .env from repo root
repo_root = _repo_root_from_here()
dotenv_path = repo_root / ".env"
load_dotenv(dotenv_path=dotenv_path, override=False)
# Environment
env_str = os.getenv("COC_ENV", "dev").strip().lower()
if env_str == "dev":
env_val = Environment.DEV
elif env_str == "test":
env_val = Environment.TEST
elif env_str == "prod":
env_val = Environment.PROD
else:
raise ValueError(f"COC_ENV must be one of dev|test|prod, got '{env_str}'")
# Construct settings
_settings_singleton = Settings(
env=env_val,
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper(),
)
return _settings_singleton

27
app/utils/tokens.py Normal file
View File

@@ -0,0 +1,27 @@
import time
from flask import session
from ..services.appwrite_client import AppwriteAccountClient
def ensure_fresh_appwrite_jwt(skew_seconds: int = 120) -> str:
"""
Returns a valid Appwrite JWT, refreshing it if it's missing or expiring soon.
Relies on the saved Appwrite session cookie in Flask's session.
"""
jwt_info = session.get("appwrite_jwt")
now = int(time.time())
if jwt_info and isinstance(jwt_info, dict):
exp = int(jwt_info.get("expire", 0))
# If token still safely valid, reuse it
if exp - now > skew_seconds and "jwt" in jwt_info:
return jwt_info["jwt"]
# Need to mint a new JWT using the user's Appwrite session cookie
cookies = session.get("appwrite_cookies")
if not cookies:
raise RuntimeError("Missing Appwrite session; user must sign in again.")
aw = AppwriteAccountClient(cookies=cookies)
new_jwt = aw.create_jwt() # -> {"jwt": "...", "expire": <unix>}
session["appwrite_jwt"] = new_jwt
return new_jwt["jwt"]