Initial commit
This commit is contained in:
0
src/xmpp_api/__init__.py
Normal file
0
src/xmpp_api/__init__.py
Normal file
61
src/xmpp_api/api/bot.py
Normal file
61
src/xmpp_api/api/bot.py
Normal 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]
|
||||
6
src/xmpp_api/api/error.py
Normal file
6
src/xmpp_api/api/error.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Error(BaseModel):
|
||||
# The error message
|
||||
msg: str
|
||||
30
src/xmpp_api/cli/add-user.py
Normal file
30
src/xmpp_api/cli/add-user.py
Normal 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()
|
||||
22
src/xmpp_api/config/config.py
Normal file
22
src/xmpp_api/config/config.py
Normal 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
121
src/xmpp_api/db/__init__.py
Normal 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
62
src/xmpp_api/db/bot.py
Normal 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
13
src/xmpp_api/db/user.py
Normal 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
207
src/xmpp_api/main.py
Normal 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)
|
||||
24
src/xmpp_api/util/constraints.py
Normal file
24
src/xmpp_api/util/constraints.py
Normal 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
5
src/xmpp_api/util/db.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import uuid as py_uuid
|
||||
|
||||
|
||||
def uuid() -> str:
|
||||
return py_uuid.uuid4().hex
|
||||
6
src/xmpp_api/util/token.py
Normal file
6
src/xmpp_api/util/token.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from secrets import token_bytes
|
||||
|
||||
|
||||
def generate_token(length: int = 32) -> str:
|
||||
# TODO: PBKDF2?
|
||||
return token_bytes(length).hex()
|
||||
Reference in New Issue
Block a user