309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Flask API and application factory for the reusable bot framework."""
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import flask
|
|
from dotenv import load_dotenv
|
|
|
|
from api.security import jsonObject, requireService, requireUser
|
|
from core import api_keys as apiKeys
|
|
from core import auth, identity, outbox, postgres, users
|
|
from core.registry import discover_modules
|
|
|
|
|
|
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
|
|
|
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def createApp():
|
|
app = flask.Flask(__name__)
|
|
app.config["MAX_CONTENT_LENGTH"] = int(
|
|
os.getenv("MAX_REQUEST_BYTES", 1024 * 1024)
|
|
)
|
|
app.config["SERVICE_KEY_READY"] = False
|
|
|
|
moduleRegistry = discover_modules()
|
|
_registerCoreRoutes(app)
|
|
for moduleName, registerRoutes in moduleRegistry.route_registrars:
|
|
logger.info("Registering routes for module %s", moduleName)
|
|
registerRoutes(app)
|
|
|
|
@app.before_request
|
|
def bootstrapConfiguredServiceKey():
|
|
if flask.request.path == "/health/live":
|
|
return None
|
|
if not app.config["SERVICE_KEY_READY"] and os.getenv("BOT_API_KEY"):
|
|
apiKeys.bootstrapServiceApiKey()
|
|
app.config["SERVICE_KEY_READY"] = True
|
|
return None
|
|
|
|
@app.errorhandler(404)
|
|
def notFound(_error):
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
|
|
@app.errorhandler(405)
|
|
def methodNotAllowed(_error):
|
|
return flask.jsonify({"error": "method not allowed"}), 405
|
|
|
|
@app.errorhandler(413)
|
|
def requestTooLarge(_error):
|
|
return flask.jsonify({"error": "request too large"}), 413
|
|
|
|
@app.errorhandler(Exception)
|
|
def unexpectedError(error):
|
|
logger.exception("Unhandled API error", exc_info=error)
|
|
return flask.jsonify({"error": "internal server error"}), 500
|
|
|
|
return app
|
|
|
|
|
|
def _registerCoreRoutes(app):
|
|
@app.route("/api/register", methods=["POST"])
|
|
def api_register():
|
|
data = jsonObject()
|
|
if data is None:
|
|
return flask.jsonify({"error": "JSON object required"}), 400
|
|
try:
|
|
registered = users.registerUser(
|
|
data.get("username"), data.get("password"), data
|
|
)
|
|
except ValueError as error:
|
|
return flask.jsonify({"error": str(error)}), 400
|
|
if not registered:
|
|
return flask.jsonify({"error": "username taken"}), 409
|
|
return flask.jsonify({"success": True}), 201
|
|
|
|
@app.route("/api/login", methods=["POST"])
|
|
def api_login():
|
|
data = jsonObject()
|
|
if data is None:
|
|
return flask.jsonify({"error": "JSON object required"}), 400
|
|
token = auth.getLoginToken(data.get("username"), data.get("password"))
|
|
if not token:
|
|
return flask.jsonify({"error": "invalid credentials"}), 401
|
|
return flask.jsonify({"token": token}), 200
|
|
|
|
@app.route("/api/auth/discord/session", methods=["POST"])
|
|
@requireService("discord:session")
|
|
def api_discordSession():
|
|
data = jsonObject()
|
|
if data is None or not data.get("discord_id"):
|
|
return flask.jsonify({"error": "discord_id required"}), 400
|
|
try:
|
|
user = identity.getOrCreateDiscordUser(
|
|
data["discord_id"], displayName=data.get("display_name")
|
|
)
|
|
except ValueError as error:
|
|
return flask.jsonify({"error": str(error)}), 400
|
|
if not user:
|
|
return flask.jsonify({"error": "Discord user is not allowed"}), 403
|
|
token = auth.createLoginToken(
|
|
str(user["id"]),
|
|
name=data.get("display_name"),
|
|
extraClaims={"provider": "discord"},
|
|
)
|
|
return flask.jsonify(
|
|
{
|
|
"token": token,
|
|
"user_uuid": user["id"],
|
|
"timezone": user.get("timezone") or "UTC",
|
|
}
|
|
), 200
|
|
|
|
@app.route("/api/keys", methods=["GET"])
|
|
@requireUser(requireLogin=True)
|
|
def api_listKeys():
|
|
return flask.jsonify(
|
|
{"keys": apiKeys.listUserApiKeys(flask.g.user_uuid)}
|
|
), 200
|
|
|
|
@app.route("/api/keys", methods=["POST"])
|
|
@requireUser(requireLogin=True)
|
|
def api_createKey():
|
|
data = jsonObject()
|
|
if data is None:
|
|
return flask.jsonify({"error": "JSON object required"}), 400
|
|
try:
|
|
key = apiKeys.createUserApiKey(
|
|
flask.g.user_uuid,
|
|
data.get("name"),
|
|
expiresAt=data.get("expires_at"),
|
|
)
|
|
except ValueError as error:
|
|
return flask.jsonify({"error": str(error)}), 400
|
|
return flask.jsonify(key), 201
|
|
|
|
@app.route("/api/keys/<keyID>", methods=["DELETE"])
|
|
@requireUser(requireLogin=True)
|
|
def api_revokeKey(keyID):
|
|
if not apiKeys.revokeUserApiKey(flask.g.user_uuid, keyID):
|
|
return flask.jsonify({"error": "API key not found"}), 404
|
|
return flask.jsonify({"success": True}), 200
|
|
|
|
@app.route("/api/getUserUUID/<username>", methods=["GET"])
|
|
@requireUser()
|
|
def api_getUserUUID(username):
|
|
userUUID = users.getUserUUID(username)
|
|
if not userUUID:
|
|
return flask.jsonify({"error": "user not found"}), 404
|
|
if str(userUUID) != str(flask.g.user_uuid):
|
|
return flask.jsonify({"error": "unauthorized"}), 403
|
|
return flask.jsonify(userUUID), 200
|
|
|
|
@app.route("/api/user/<userUUID>", methods=["GET"])
|
|
@requireUser()
|
|
def api_getUser(userUUID):
|
|
if str(userUUID) != str(flask.g.user_uuid):
|
|
return flask.jsonify({"error": "unauthorized"}), 403
|
|
user = users.getUser(userUUID)
|
|
if not user:
|
|
return flask.jsonify({"error": "user not found"}), 404
|
|
user.pop("password_hashed", None)
|
|
return flask.jsonify(user), 200
|
|
|
|
@app.route("/api/user/<userUUID>", methods=["PUT"])
|
|
@requireUser()
|
|
def api_updateUser(userUUID):
|
|
if str(userUUID) != str(flask.g.user_uuid):
|
|
return flask.jsonify({"error": "unauthorized"}), 403
|
|
data = jsonObject()
|
|
if data is None:
|
|
return flask.jsonify({"error": "JSON object required"}), 400
|
|
try:
|
|
updated = users.updateUser(userUUID, data)
|
|
except ValueError as error:
|
|
return flask.jsonify({"error": str(error)}), 400
|
|
if not updated:
|
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
|
return flask.jsonify({"success": True}), 200
|
|
|
|
@app.route("/api/user/<userUUID>", methods=["DELETE"])
|
|
@requireUser(requireLogin=True)
|
|
def api_deleteUser(userUUID):
|
|
if str(userUUID) != str(flask.g.user_uuid):
|
|
return flask.jsonify({"error": "unauthorized"}), 403
|
|
data = jsonObject()
|
|
if data is None or not data.get("password"):
|
|
return flask.jsonify(
|
|
{"error": "password required for account deletion"}
|
|
), 400
|
|
if not auth.unregisterUser(userUUID, data["password"]):
|
|
return flask.jsonify({"error": "invalid password"}), 401
|
|
return flask.jsonify({"success": True}), 200
|
|
|
|
@app.route("/api/user/me/timezone", methods=["GET", "PUT"])
|
|
@requireUser()
|
|
def api_userTimezone():
|
|
if flask.request.method == "GET":
|
|
return flask.jsonify(
|
|
{"timezone": users.getUserTimezone(flask.g.user_uuid)}
|
|
), 200
|
|
data = jsonObject()
|
|
if data is None:
|
|
return flask.jsonify({"error": "JSON object required"}), 400
|
|
try:
|
|
timezoneName = users.normalizeTimezone(data.get("timezone"))
|
|
users.updateUser(flask.g.user_uuid, {"timezone": timezoneName})
|
|
except ValueError as error:
|
|
return flask.jsonify({"error": str(error)}), 400
|
|
return flask.jsonify({"timezone": timezoneName}), 200
|
|
|
|
@app.route("/api/internal/outbox/claim", methods=["POST"])
|
|
@requireService("outbox:claim")
|
|
def api_claimOutbox():
|
|
data = jsonObject() or {}
|
|
workerID = data.get("worker_id")
|
|
if not workerID:
|
|
return flask.jsonify({"error": "worker_id required"}), 400
|
|
try:
|
|
claimed = outbox.claim_messages(
|
|
workerID,
|
|
channel=data.get("channel", "discord_dm"),
|
|
limit=data.get("limit", 20),
|
|
)
|
|
except ValueError as error:
|
|
return flask.jsonify({"error": str(error)}), 400
|
|
|
|
messages = []
|
|
for message in claimed:
|
|
discordIdentity = _discordIdentityForUser(message["user_uuid"])
|
|
if not discordIdentity:
|
|
outbox.retry_message(
|
|
message["id"], workerID, "user has no Discord identity"
|
|
)
|
|
continue
|
|
payload = message.get("payload") or {}
|
|
messages.append(
|
|
{
|
|
"id": message["id"],
|
|
"provider_user_id": discordIdentity["provider_user_id"],
|
|
"content": payload.get("content", ""),
|
|
"attempts": message["attempts"],
|
|
"worker_id": workerID,
|
|
}
|
|
)
|
|
return flask.jsonify({"messages": messages}), 200
|
|
|
|
@app.route("/api/internal/outbox/<messageID>/result", methods=["POST"])
|
|
@requireService("outbox:deliver")
|
|
def api_outboxResult(messageID):
|
|
data = jsonObject()
|
|
if data is None or data.get("status") not in {"sent", "retry"}:
|
|
return flask.jsonify({"error": "valid status required"}), 400
|
|
message = outbox.get_message(messageID)
|
|
if not message or message.get("status") != "delivering":
|
|
return flask.jsonify({"error": "leased message not found"}), 404
|
|
workerID = message.get("leased_by")
|
|
if not data.get("worker_id") or data["worker_id"] != workerID:
|
|
return flask.jsonify({"error": "message lease is owned by another worker"}), 409
|
|
if data["status"] == "sent":
|
|
externalMessageID = data.get("external_message_id")
|
|
if externalMessageID is not None:
|
|
externalMessageID = str(externalMessageID)[:255]
|
|
updated = outbox.mark_delivered(
|
|
messageID,
|
|
workerID,
|
|
external_message_id=externalMessageID,
|
|
)
|
|
else:
|
|
updated = outbox.retry_message(
|
|
messageID, workerID, data.get("error", "delivery failed")
|
|
)
|
|
if not updated:
|
|
return flask.jsonify({"error": "message lease expired"}), 409
|
|
return flask.jsonify({"success": True, "message": updated}), 200
|
|
|
|
@app.route("/health/live", methods=["GET"])
|
|
def healthLive():
|
|
return flask.jsonify({"status": "ok"}), 200
|
|
|
|
@app.route("/health/ready", methods=["GET"])
|
|
def healthReady():
|
|
try:
|
|
postgres.execute("SELECT 1 AS ready")
|
|
except Exception:
|
|
return flask.jsonify({"status": "not ready"}), 503
|
|
return flask.jsonify({"status": "ready"}), 200
|
|
|
|
@app.route("/health", methods=["GET"])
|
|
def healthCompatibility():
|
|
return healthReady()
|
|
|
|
|
|
def _discordIdentityForUser(userUUID):
|
|
identities = identity.listUserIdentities(userUUID)
|
|
return next(
|
|
(item for item in identities if item.get("provider") == "discord"), None
|
|
)
|
|
|
|
|
|
app = createApp()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=5000)
|