init commit
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
instance/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# app specific
|
||||||
61
app/__init__.py
Normal 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
|
||||||
158
app/blueprints/auth/routes.py
Normal 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"))
|
||||||
18
app/blueprints/main/routes.py
Normal 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)
|
||||||
84
app/services/appwrite_client.py
Normal 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
6
app/static/css/style.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
html {
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "Martian Mono", monospace !important;
|
||||||
|
}
|
||||||
BIN
app/static/images/COC_Icon.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
app/static/images/COC_Logo.png
Normal file
|
After Width: | Height: | Size: 861 KiB |
BIN
app/static/images/favicons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
app/static/images/favicons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
app/static/images/favicons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
app/static/images/favicons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 776 B |
BIN
app/static/images/favicons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/static/images/favicons/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
35
app/templates/_flash_sticky.html
Normal 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 %}
|
||||||
28
app/templates/auth/login.html
Normal 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 %}
|
||||||
36
app/templates/auth/register.html
Normal 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 %}
|
||||||
40
app/templates/bases/login_base.html
Normal 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">© 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>
|
||||||
65
app/templates/bases/main_base.html
Normal 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">© 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>
|
||||||
40
app/templates/bases/side_nav.html
Normal 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>
|
||||||
19
app/templates/main/dashboard.html
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
159
docs/arch.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# High-level view
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Browser / Web GUI ]
|
||||||
|
│ HTTPS (HTTP/3) via Cloudflare (DNS/WAF/CDN)
|
||||||
|
▼
|
||||||
|
[ Caddy API Gateway ] — routing, TLS, real client IP, SSE/WebSocket pass-through
|
||||||
|
│
|
||||||
|
├── /auth/* → [ Auth Service (Appwrite) ]
|
||||||
|
├── /api/* → [ Game API (Flask) ]
|
||||||
|
│ ├── calls → [ AI-DM Service (Flask) → Replicate ]
|
||||||
|
│ └── calls → [ Embeddings Service (Flask) ]
|
||||||
|
│ └── KNN over pgvector
|
||||||
|
│
|
||||||
|
├── presign → direct upload/download ↔ [ Appwrite ]
|
||||||
|
│
|
||||||
|
└── infra cache / rate limits ↔ [ Redis ]
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [ Postgres 16 + pgvector ] │
|
||||||
|
│ (auth, game OLTP + semantic vectors) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
## Front end / Back end auth
|
||||||
|
---
|
||||||
|
```
|
||||||
|
[Frontend] ── login(email,pass) ─▶ [API Gateway] ─▶ [Auth Service]
|
||||||
|
│ │
|
||||||
|
│ <── Set-Cookie: access_token=JWT ───┘
|
||||||
|
│
|
||||||
|
├─▶ call /game/start (cookie auto-attached)
|
||||||
|
│
|
||||||
|
└─▶ logout → clear cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Services & responsibilities
|
||||||
|
|
||||||
|
* **Web GUI**
|
||||||
|
|
||||||
|
* Player UX for auth, character management, sessions, chat.
|
||||||
|
* Uses REST for CRUD; SSE/WebSocket for live DM replies/typing.
|
||||||
|
|
||||||
|
* **Caddy API Gateway**
|
||||||
|
|
||||||
|
* Edge routing for `/auth`, `/api`, `/ai`, `/vec`.
|
||||||
|
* TLS termination behind Cloudflare; preserves real client IP; gzip/br.
|
||||||
|
* Pass-through for SSE/WebSocket; access logging.
|
||||||
|
|
||||||
|
* **Auth Service (Flask)**
|
||||||
|
|
||||||
|
* Registration, login, refresh; JWT issuance/validation.
|
||||||
|
* Owns player identity and credentials.
|
||||||
|
* Simple rate limits via Redis.
|
||||||
|
|
||||||
|
* **Game API (Flask)**
|
||||||
|
|
||||||
|
* Core game domain (characters, sessions, inventory, rules orchestration).
|
||||||
|
* Persists messages; orchestrates retrieval and AI calls.
|
||||||
|
* Streams DM replies to clients (SSE/WebSocket).
|
||||||
|
* Generates pre-signed URLs for Garage uploads/downloads.
|
||||||
|
|
||||||
|
* **AI-DM Service (Flask)**
|
||||||
|
|
||||||
|
* Thin, deterministic wrapper around **Replicate** models (prompt shaping, retries, timeouts).
|
||||||
|
* Optional async path via job queue if responses are slow.
|
||||||
|
|
||||||
|
* **Embeddings Service (Flask)**
|
||||||
|
|
||||||
|
* Text → vector embedding (chosen model) and vector writes.
|
||||||
|
* KNN search API (top-K over `pgvector`) for context retrieval.
|
||||||
|
* Manages embedding version/dimension; supports re-embed workflows.
|
||||||
|
|
||||||
|
* **Postgres 16 + pgvector**
|
||||||
|
|
||||||
|
* Single source of truth for auth & game schemas.
|
||||||
|
* Stores messages with `vector` column; IVF/HNSW index for similarity.
|
||||||
|
|
||||||
|
* **Garage (S3-compatible)**
|
||||||
|
|
||||||
|
* Object storage for player assets (character sheets, images, exports).
|
||||||
|
* Access via pre-signed URLs (private buckets by default).
|
||||||
|
|
||||||
|
* **Redis**
|
||||||
|
|
||||||
|
* Caching hot reads (recent messages/session state).
|
||||||
|
* Rate limiting tokens; optional Dramatiq broker for long jobs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data boundaries
|
||||||
|
|
||||||
|
* **Auth schema (Postgres)**
|
||||||
|
|
||||||
|
* `players(id, email, password_hash, created_at, …)`
|
||||||
|
* Service: **Auth** exclusively reads/writes; others read via Auth or JWT claims.
|
||||||
|
|
||||||
|
* **Game schema (Postgres)**
|
||||||
|
|
||||||
|
* `characters(id, player_id, name, clazz, level, sheet_json, …)`
|
||||||
|
* `sessions(id, player_id, title, created_at, …)`
|
||||||
|
* `messages(id, session_id, role, content, embedding vector(…)=NULL, created_at, …)`
|
||||||
|
* Indices:
|
||||||
|
|
||||||
|
* `messages(session_id, created_at)`
|
||||||
|
* `messages USING hnsw|ivfflat (embedding vector_cosine_ops)`
|
||||||
|
|
||||||
|
* **Objects (Garage)**
|
||||||
|
|
||||||
|
* Buckets: `player-assets`, `exports`, etc.
|
||||||
|
* Keys include tenant/player and content hashes; metadata stored in DB.
|
||||||
|
|
||||||
|
* **Cache/queues (Redis)**
|
||||||
|
|
||||||
|
* Keys for rate limits, short-lived session state, optional job queues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core request flows
|
||||||
|
|
||||||
|
### A) Player message → DM reply (sync POC)
|
||||||
|
|
||||||
|
1. Web GUI → `POST /api/sessions/{id}/messages` (JWT).
|
||||||
|
2. **Game API** writes player message (content only).
|
||||||
|
3. **Embeddings Service** returns vector → **Game API** updates message.embedding.
|
||||||
|
4. **Embeddings Service** (or direct SQL) performs KNN to fetch top-K prior messages.
|
||||||
|
5. **Game API** calls **AI-DM Service** with `{prompt, context, system}`.
|
||||||
|
6. **AI-DM** calls **Replicate**, returns text.
|
||||||
|
7. **Game API** writes DM message (+ embedding), emits SSE/WebSocket event to client.
|
||||||
|
|
||||||
|
### B) Asset upload (character sheet/map)
|
||||||
|
|
||||||
|
1. Web GUI → `POST /api/assets/presign {bucket, key, contentType}` (JWT).
|
||||||
|
2. **Game API** validates ACLs → returns pre-signed PUT URL for **Garage**.
|
||||||
|
3. Browser uploads directly to **Garage**.
|
||||||
|
4. **Game API** records/updates asset metadata row (owner, key, checksum, type).
|
||||||
|
|
||||||
|
### C) Authentication
|
||||||
|
|
||||||
|
1. Web GUI → **Auth** `POST /auth/register` / `POST /auth/login`.
|
||||||
|
2. **Auth** returns `{access, refresh}` JWTs.
|
||||||
|
3. Subsequent API calls include access token (Caddy passes through).
|
||||||
|
|
||||||
|
### D) Retrieval-augmented turn (refine/search only)
|
||||||
|
|
||||||
|
1. **Game API** (server-side) computes query embedding for player prompt.
|
||||||
|
2. KNN over `messages.embedding` returns top-K context.
|
||||||
|
3. Context trimmed/serialized and sent to **AI-DM Service**.
|
||||||
|
4. Reply streamed back to client; transcripts persisted.
|
||||||
|
|
||||||
|
### E) Long/slow generations (async job queue)
|
||||||
|
|
||||||
|
1. **Game API** enqueues job (Redis/Dramatiq) to **AI-DM**.
|
||||||
|
2. Returns `{job_id}`; Web GUI subscribes via SSE.
|
||||||
|
3. Worker completes → **Game API** writes DM message and emits event.
|
||||||
|
|
||||||
|
This keeps each service small and focused, leans on Flask everywhere, uses **Caddy + Cloudflare** at the edge, **Postgres + pgvector** for state and search, and **Garage** for durable assets—with clean seams to swap pieces as you scale.
|
||||||
22
docs/file_layout.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# File layout for Code of Conquest
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── app
|
||||||
|
│ ├── core
|
||||||
|
│ │ ├── prompts # API Prompts
|
||||||
|
│ │ ├── providers # External Service Providers (AI providers, and other APIs)
|
||||||
|
│ │ └── utils # logging, setttings, common utilities (common functions)
|
||||||
|
│ │ ├── common_utils.py
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── logging_setup.py
|
||||||
|
│ │ └── settings.py
|
||||||
|
│ └── __init__.py
|
||||||
|
├── coc.py # Main Launcher - CLI entrypoint: run TUI, run Web, generate campaign, etc.
|
||||||
|
├── data
|
||||||
|
│ ├── campaigns # Campaign data (temp until DB is setup)
|
||||||
|
│ └── players # Player Data (temp until DB is setup)
|
||||||
|
├── docs
|
||||||
|
│ └── file_layout.md
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
1
docs/theme.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
General theme idea from: https://github.com/enindu/admin-panel/blob/master/items.html
|
||||||
19
old_dashboard.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set header = "Dashboard" %}
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<hgroup>
|
||||||
|
<h2>Welcome, {{ (profile.name or profile.email) if profile else current_user.email }}</h2>
|
||||||
|
<p>Your Appwrite user ID: <code>{{ profile["$id"] }}</code></p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>JWT info</summary>
|
||||||
|
<pre>{{ jwt_info | tojson(indent=2) }}</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||||
|
<button type="submit" class="secondary">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
14
requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
blinker==1.9.0
|
||||||
|
certifi==2025.10.5
|
||||||
|
charset-normalizer==3.4.4
|
||||||
|
click==8.3.0
|
||||||
|
Flask==3.1.2
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
idna==3.11
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
requests==2.32.5
|
||||||
|
urllib3==2.5.0
|
||||||
|
Werkzeug==3.1.3
|
||||||