Initial commit

This commit is contained in:
2025-04-20 23:22:04 +02:00
commit 054f182215
17 changed files with 1312 additions and 0 deletions

0
src/xmpp_api/__init__.py Normal file
View File

61
src/xmpp_api/api/bot.py Normal file
View File

@@ -0,0 +1,61 @@
from pydantic import BaseModel, Field
class BotInformation(BaseModel):
# The bot's ID
id: str
# The bot's name
name: str
# The bot's description
description: str | None
# List of constraints on the bot
constraints: list["BotConstraint"]
class AllowedJidInformation(BaseModel):
jid: str
token: str
class BotConstraint(BaseModel): ...
class BotDomainConstraint(BotConstraint):
domains: list[str]
class CreateBotRequest(BaseModel):
# The bot's name
name: str
# The bot's description
description: str | None = Field(default=None)
# List of constraints
constraints: list[BotConstraint] = Field(default_factory=list)
class BotCreationResponse(BotInformation):
# The bot's token
token: str
class AddJidRequest(BaseModel):
jid: str
class AddJidResponse(BaseModel):
token: str
class SendMessageRequest(BaseModel):
# The message content
body: str
class GetBotInformation(BotInformation):
jids: list[AllowedJidInformation]

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class Error(BaseModel):
# The error message
msg: str

View File

@@ -0,0 +1,30 @@
import uuid
from sqlmodel import SQLModel
from xmpp_api.config.config import load_config
from xmpp_api.db import get_session, get_engine
from xmpp_api.db.user import User
from xmpp_api.util.token import generate_token
def main():
# Create all tables
engine = get_engine(load_config())
SQLModel.metadata.create_all(engine)
# Create the user
session = next(get_session(engine))
user = User(
id=uuid.uuid4().hex,
name="alexander",
token=generate_token(64),
)
session.add(user)
session.commit()
print(f"User token: {user.token}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,22 @@
from typing import Annotated
from pydantic import BaseModel
from fastapi import Depends
class _Config(BaseModel):
# DB URI for sqlmodel
database: str
def load_config() -> _Config:
"""
Load the application config
"""
# TODO: Actually load it
return _Config(
database="sqlite:///db.sqlite3",
)
ConfigDep = Annotated[_Config, Depends(load_config)]

121
src/xmpp_api/db/__init__.py Normal file
View File

@@ -0,0 +1,121 @@
from typing import Annotated, Generator, Sequence, cast
from fastapi import Depends, Request, HTTPException
from sqlmodel import SQLModel, Session, create_engine, select
from sqlalchemy import Engine
from xmpp_api.config.config import ConfigDep
from xmpp_api.db.user import User
from xmpp_api.db.bot import Bot, AllowedJid
def get_engine(config: ConfigDep) -> Engine:
return create_engine(
config.database,
connect_args={
"check_same_thread": False,
},
)
EngineDep = Annotated[Engine, Depends(get_engine)]
def get_session(engine: EngineDep) -> Generator[Session]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
def get_by_token(
cls: type[SQLModel], request: Request, session: SessionDep
) -> SQLModel:
authorization = request.headers.get("Authorization")
if authorization is None:
raise HTTPException(
detail="No authentication provided",
status_code=400,
)
auth_type, token = authorization.split(" ")
if auth_type != "Bearer":
raise HTTPException(
detail="Invalid token type provided",
status_code=400,
)
obj = session.exec(select(cls).where(cls.token == token)).first()
if obj is None:
raise HTTPException(
detail="Unauthorized",
status_code=403,
)
return obj
def get_user(request: Request, session: SessionDep) -> User:
return cast(User, get_by_token(User, request, session))
UserDep = Annotated[User, Depends(get_user)]
def get_bot(request: Request, session: SessionDep) -> Bot:
return cast(Bot, get_by_token(Bot, request, session))
BotDep = Annotated[Bot, Depends(get_bot)]
def get_bot_by_id(bot_id: str, user_id: str, session: SessionDep) -> Bot | None:
"""
Fetches the specified bot from the database.
Args
:bot_id The ID of the bot.
:user_id The ID of the authenticated user.
:session_dep The database session
Returns
Bot | None: The bot object, if found, or None.
"""
return session.exec(
select(Bot).where(Bot.id == bot_id, Bot.owner_id == user_id)
).first()
def get_jids_by_bot_id(bot_id: str, session: SessionDep) -> Sequence[AllowedJid]:
"""
Retrieve all AllowedJid objects associated with a given bot_id from the database.
Args:
bot_id (str): The ID of the bot for which to retrieve AllowedJids.
session (SessionDep): A FastAPI dependency that provides access to the database session.
Returns:
Sequence[AllowedJid]: A sequence of AllowedJid objects associated with the given bot_id.
"""
return session.exec(
select(AllowedJid).where(AllowedJid.bot_id == bot_id),
).all()
def get_jid_by_jid_token(jid_token: str, session: SessionDep) -> AllowedJid | None:
"""
Retrieve an AllowedJid object from the database based on the provided jid_token.
Args:
jid_token (str): The token associated with the AllowedJid to retrieve.
session (SessionDep): A FastAPI dependency that provides access to the database session.
Returns:
AllowedJid | None: The AllowedJid object associated with the given jid_token, or None if no match is found.
"""
return session.exec(
select(AllowedJid).where(AllowedJid.token == jid_token),
).first()

62
src/xmpp_api/db/bot.py Normal file
View File

@@ -0,0 +1,62 @@
from enum import Enum
from sqlmodel import SQLModel, Field, JSON
from xmpp_api.util.db import uuid
class JIDType(Enum):
"""
The type of entity the JID is pointing to.
"""
# JID points to an entity we can directly send messages to
DIRECT = 1
# JID points to a MUC.
GC = 2
class AllowedJid(SQLModel, table=True):
# The relevant JID
jid: str = Field(primary_key=True)
# The type of entity the JID points to
type: JIDType
# The token that the client must use to specify the destination
token: str
# The bot this jid belongs to
bot_id: str = Field(foreign_key="bot.id", primary_key=True)
class BotConstraint(SQLModel): ...
class BotDomainConstraint(BotConstraint):
"""
Constraints the bot to send messages only to the provided domains
"""
domains: list[str]
class Bot(SQLModel, table=True):
# The bot ID
id: str = Field(default_factory=uuid, primary_key=True)
# The bot's human readable name
name: str = Field(unique=True)
# The bot's description
description: str | None
# The bot's token
token: str
# Constraints on the bot
constraints: list[BotConstraint] = Field(sa_type=JSON)
# The owner the Bot belongs to
owner_id: str = Field(foreign_key="user.id")

13
src/xmpp_api/db/user.py Normal file
View File

@@ -0,0 +1,13 @@
from sqlmodel import Field, SQLModel
from xmpp_api.util.db import uuid
class User(SQLModel, table=True):
# The ID of the user
id: str = Field(default_factory=uuid, primary_key=True)
name: str
# Access token
token: str

207
src/xmpp_api/main.py Normal file
View File

@@ -0,0 +1,207 @@
import uuid
from fastapi import FastAPI, HTTPException, Request, Response
import sqlalchemy
from sqlmodel import SQLModel, select
from xmpp_api.config.config import load_config
from xmpp_api.api.bot import (
AddJidRequest,
AddJidResponse,
AllowedJidInformation,
BotCreationResponse,
BotInformation,
CreateBotRequest,
GetBotInformation,
SendMessageRequest,
BotConstraint,
BotDomainConstraint,
)
from xmpp_api.db import BotDep, SessionDep, UserDep, EngineDep, get_engine
from xmpp_api.db.bot import AllowedJid, Bot, JIDType
import xmpp_api.db.bot as db_bot
from xmpp_api.util.token import generate_token
from xmpp_api.db import get_bot_by_id, get_jids_by_bot_id, get_jid_by_jid_token
from xmpp_api.util.constraints import bot_constraint_to_db, bot_constraint_from_db
app = FastAPI()
@app.on_event("startup")
def startup():
# TODO: This is kinda ugly
engine = app.dependency_overrides.get(get_engine, get_engine)(
load_config(),
)
SQLModel.metadata.create_all(engine)
@app.post("/api/v1/bot/create")
def post_create_bot(
bot_request: CreateBotRequest, user: UserDep, session: SessionDep
) -> BotCreationResponse:
# Convert the provided constraints into DB-type bot constraints
try:
constraints: list[db_bot.BotConstraint] = [
bot_constraint_to_db(c) for c in bot_request.constraints
]
except Exception:
raise HTTPException(status_code=500)
bot = Bot(
name=bot_request.name,
description=bot_request.description,
token=generate_token(64),
constraints=constraints,
owner_id=user.id,
)
session.add(bot)
# Handle errors caused by the unique constraint
try:
session.commit()
except sqlalchemy.exc.IntegrityError:
raise HTTPException(
status_code=400,
detail="The name is already in use",
)
return BotCreationResponse(
id=bot.id,
name=bot.name,
description=bot.description,
token=bot.token,
constraints=bot_request.constraints,
)
@app.post("/api/v1/bot/{bot_id}/jid")
def post_create_bot_jid(
bot_id: str, creation_request: AddJidRequest, user: UserDep, session: SessionDep
) -> AddJidResponse:
# Check if the bot exists and we own it
bot = get_bot_by_id(bot_id, user.id, session)
if bot is None:
raise HTTPException(
status_code=404,
detail="Unknown bot",
)
# Add the JID
# TODO: Query for the JID type
# TODO: If this is a groupchat, then join it
jid = AllowedJid(
jid=creation_request.jid,
type=JIDType.DIRECT,
# This token is only for identification
token=uuid.uuid4().hex,
bot_id=bot.id,
)
session.add(jid)
try:
session.commit()
except sqlalchemy.exc.IntegrityError:
raise HTTPException(
status_code=400,
detail="The JID already exists",
)
return AddJidResponse(
token=jid.token,
)
@app.delete("/api/v1/bot/{bot_id}/jid/{jid_token}")
def delete_bot_jid_token(
bot_id: str, jid_token: str, user: UserDep, session: SessionDep
):
jid = get_jid_by_jid_token(jid_token, session)
if jid is None or jid.bot_id != bot_id:
raise HTTPException(
status_code=404,
)
session.delete(jid)
session.commit()
@app.post("/api/v1/bot/message/{jid_token}")
def post_bot_message(
jid_token: str,
message: SendMessageRequest,
request: Request,
bot: BotDep,
session: SessionDep,
):
# Is the bot allowed to access this JID?
jid = session.exec(
select(AllowedJid).where(
AllowedJid.token == jid_token, AllowedJid.bot_id == bot.id
)
).first()
if jid is None:
raise HTTPException(
detail="Unknown JID token",
status_code=404,
)
# TODO: Send a message
return Response(status_code=200)
@app.get("/api/v1/bot/{bot_id}")
def get_bot_information(
bot_id: str, user: UserDep, session: SessionDep
) -> GetBotInformation:
# Find the bot
bot = get_bot_by_id(bot_id, user.id, session)
if bot is None:
raise HTTPException(status_code=404)
try:
constraints: list[BotConstraint] = [
bot_constraint_from_db(c) for c in bot.constraints
]
except Exception:
raise HTTPException(status_code=500)
return GetBotInformation(
id=bot.id,
name=bot.name,
description=bot.description,
jids=[
AllowedJidInformation(
jid=jid.jid,
token=jid.token,
)
for jid in get_jids_by_bot_id(bot.id, session)
],
constraints=constraints,
)
@app.get("/api/v1/user/bots")
def get_bots(user: UserDep, session: SessionDep) -> list[BotInformation]:
bots = session.exec(select(Bot).where(Bot.owner_id == user.id)).all()
return [
BotInformation(
id=bot.id,
name=bot.name,
description=bot.description,
constraints=[bot_constraint_from_db(c) for c in bot.constraints],
)
for bot in bots
]
@app.delete("/api/v1/bot/{bot_id}")
def delete_bot(bot_id: str, user: UserDep, session: SessionDep):
bot = get_bot_by_id(bot_id, user.id, session)
if bot is None:
raise HTTPException(status_code=404)
for jid in get_jids_by_bot_id(bot_id, session):
session.delete(jid)
session.delete(bot)
session.commit()
return Response(status_code=200)

View File

@@ -0,0 +1,24 @@
import xmpp_api.api.bot as bot_api
import xmpp_api.db.bot as bot_db
def bot_constraint_to_db(constraint: bot_api.BotConstraint) -> bot_db.BotConstraint:
match constraint:
case bot_api.BotDomainConstraint:
return bot_db.BotDomainConstraint(
domains=constraint.domains,
)
case _:
# TODO: Proper exception type
raise Exception()
def bot_constraint_from_db(constraint: bot_db.BotConstraint) -> bot_api.BotConstraint:
match constraint:
case bot_db.BotDomainConstraint:
return bot_api.BotDomainConstraint(
domains=constraint.domains,
)
case _:
# TODO: Proper exception type
raise Exception()

5
src/xmpp_api/util/db.py Normal file
View File

@@ -0,0 +1,5 @@
import uuid as py_uuid
def uuid() -> str:
return py_uuid.uuid4().hex

View File

@@ -0,0 +1,6 @@
from secrets import token_bytes
def generate_token(length: int = 32) -> str:
# TODO: PBKDF2?
return token_bytes(length).hex()