Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/db/migrations/versions/20251221_server_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""server access

Revision ID: 912c996d627a
Revises: 32916aca8721
Create Date: 2025-12-21 00:23:30.783469

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "912c996d627a"
down_revision: Union[str, Sequence[str], None] = "32916aca8721"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"server_accesses",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("client_id", sa.Integer(), nullable=False),
sa.Column("server_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(
["client_id"],
["clients.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("server_accesses")
# ### end Alembic commands ###
3 changes: 2 additions & 1 deletion src/db/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from ._user import User, UserMessage, UserState
from ._client import Client
from ._access import ServerAccess


__all__ = ["Client", "User", "UserMessage", "UserState"]
__all__ = ["Client", "User", "UserMessage", "UserState", "ServerAccess"]
43 changes: 43 additions & 0 deletions src/db/models/_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from sqlalchemy import BigInteger, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.future import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..core import Base


class ServerAccess(Base):
__tablename__ = "server_accesses"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(Integer, ForeignKey("clients.id"), nullable=False)
server_id: Mapped[int] = mapped_column(Integer, nullable=False)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=False)

@classmethod
async def create(cls, db: AsyncSession, *, client_id: int, server_id: int, user_id: int) -> "ServerAccess":
access = cls(client_id=client_id, server_id=server_id, user_id=user_id)
db.add(access)
await db.commit()
await db.refresh(access)
return access

@classmethod
async def get_all_by_server(cls, db: AsyncSession, client_id: int, server_id: int) -> list["ServerAccess"]:
result = await db.execute(select(cls).where(cls.client_id == client_id, cls.server_id == server_id))
return result.scalars().all()

@classmethod
async def get_by_user(cls, db: AsyncSession, user_id: int) -> list["ServerAccess"]:
result = await db.execute(select(cls).where(cls.user_id == user_id))
return result.scalars().all()

@classmethod
async def delete(cls, db: AsyncSession, client_id: int, server_id: int, user_id: int) -> None:
result = await db.execute(
select(cls).where(cls.client_id == client_id, cls.server_id == server_id, cls.user_id == user_id)
)
access = result.scalars().first()
if access:
await db.delete(access)
await db.commit()
20 changes: 17 additions & 3 deletions src/db/models/_user.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import logging
from datetime import datetime
from typing import Optional, Union
from typing import Optional, Union, TYPE_CHECKING
from eiogram.types import User as EioUser, Message, CallbackQuery
from sqlalchemy import String, BigInteger, DateTime, Integer, Text, JSON
from sqlalchemy.sql import select, delete
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.hybrid import hybrid_property

from src.config import BOT, TELEGRAM_ADMINS_ID
from ..core import Base, GetDB

if TYPE_CHECKING:
from ._access import ServerAccess


class UserState(Base):
__tablename__ = "user_states"
Expand Down Expand Up @@ -78,8 +81,18 @@ class User(Base):
join_at: Mapped[datetime] = mapped_column(DateTime(), default=datetime.now)
online_at: Mapped[datetime] = mapped_column(DateTime(), default=datetime.now)

server_accesses: Mapped[list["ServerAccess"]] = relationship("ServerAccess", backref=None, lazy="selectin")

def client_ids(self) -> set[int]:
return {access.client_id for access in self.server_accesses}

def get_server_ids(self, client_id: Optional[int] = None) -> set[int]:
if client_id is not None:
return {access.server_id for access in self.server_accesses if access.client_id == client_id}
return {access.server_id for access in self.server_accesses}

@hybrid_property
def has_access(self) -> bool:
def is_owner(self) -> bool:
return self.id in TELEGRAM_ADMINS_ID

@classmethod
Expand All @@ -104,6 +117,7 @@ async def upsert(cls, db: AsyncSession, *, user: EioUser) -> Optional["User"]:
db.add(dbuser)
logging.info(f"New user added: {dbuser.id} - {dbuser.full_name}")
await db.flush()
await db.refresh(dbuser)
return dbuser

def __repr__(self) -> str:
Expand Down
17 changes: 12 additions & 5 deletions src/handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,34 @@
from eiogram.filters import Command, IgnoreStateFilter
from eiogram.state import StateManager

from src.db import UserMessage, AsyncSession, Client
from src.db import UserMessage, AsyncSession, Client, User
from src.keys import BotKB, BotCB, AreaType, TaskType
from src.lang import Dialogs

router = Router()


@router.message(Command("start"), IgnoreStateFilter())
async def start_handler(message: Message, db: AsyncSession, state: StateManager):
async def start_handler(message: Message, db: AsyncSession, state: StateManager, dbuser: User):
await state.clear_all(db=db)
clients = await Client.get_all(db)
update = await message.answer(text=Dialogs.COMMAND_START, reply_markup=BotKB.home(clients=clients))
if not dbuser.is_owner:
clients = [client for client in clients if client.id in dbuser.client_ids()]
update = await message.answer(
text=Dialogs.COMMAND_START, reply_markup=BotKB.home(clients=clients, is_owner=dbuser.is_owner)
)
return await UserMessage.clear(update)


@router.callback_query(BotCB.filter(area=AreaType.HOME, task=TaskType.MENU), IgnoreStateFilter())
async def home_menu(callback_query: CallbackQuery, db: AsyncSession, state: StateManager):
async def home_menu(callback_query: CallbackQuery, db: AsyncSession, state: StateManager, dbuser: User):
await state.clear_all(db=db)

clients = await Client.get_all(db)
if not dbuser.is_owner:
clients = [client for client in clients if client.id in dbuser.client_ids()]
update = await callback_query.message.answer(
text=Dialogs.COMMAND_START,
reply_markup=BotKB.home(clients=clients),
reply_markup=BotKB.home(clients=clients, is_owner=dbuser.is_owner),
)
return await UserMessage.clear(update)
7 changes: 4 additions & 3 deletions src/handlers/clients/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from src.db import AsyncSession, Client, UserMessage
from src.keys import BotKB, BotCB, AreaType, TaskType
from src.lang import Dialogs
from src.utils.depends import ShouldBeOwner

router = Router()

Expand All @@ -19,13 +20,13 @@ class ClientCreateForm(StateGroup):


@router.callback_query(BotCB.filter(area=AreaType.CLIENT, task=TaskType.CREATE), IgnoreStateFilter())
async def clients_create(callback_query: CallbackQuery, db: AsyncSession, state: StateManager):
async def clients_create(callback_query: CallbackQuery, db: AsyncSession, state: StateManager, __: ShouldBeOwner):
await state.upsert_context(db=db, state=ClientCreateForm.remark)
return await callback_query.message.edit(text=Dialogs.CLIENTS_ENTER_REMARK, reply_markup=BotKB.home_back())


@router.message(StateFilter(ClientCreateForm.remark), Text())
async def remark_handler(message: Message, db: AsyncSession, state: StateManager):
async def remark_handler(message: Message, db: AsyncSession, state: StateManager, __: ShouldBeOwner):
if await Client.get_by_remark(db, message.text):
update = await message.answer(text=Dialogs.ACTIONS_DUPLICATE, reply_markup=BotKB.home_back())
return await UserMessage.clear(update)
Expand All @@ -35,7 +36,7 @@ async def remark_handler(message: Message, db: AsyncSession, state: StateManager


@router.message(StateFilter(ClientCreateForm.secret), Text())
async def secret_handler(message: Message, db: AsyncSession, state: StateManager, state_data: dict):
async def secret_handler(message: Message, db: AsyncSession, state: StateManager, state_data: dict, __: ShouldBeOwner):
try:
hetzner = HCloudClient(token=message.text)
hetzner.datacenters.get_all()
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/clients/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
from src.db import UserMessage, Client, AsyncSession
from src.lang import Dialogs
from src.keys import BotKB, BotCB, AreaType, TaskType
from src.utils.depends import ClearState
from src.utils.depends import ClearState, ShouldBeOwner

router = Router()


@router.callback_query(BotCB.filter(area=AreaType.CLIENT, task=TaskType.INFO), IgnoreStateFilter())
async def clients_info(callback_query: CallbackQuery, db: AsyncSession, state_data: dict, _: ClearState):
async def clients_info(callback_query: CallbackQuery, db: AsyncSession, state_data: dict, _: ClearState, __: ShouldBeOwner):
client = await Client.get_by_id(db, state_data["client_id"])
if not client:
return await callback_query.answer(text=Dialogs.CLIENTS_NOT_FOUND, show_alert=True)
Expand Down
6 changes: 4 additions & 2 deletions src/handlers/clients/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from eiogram.filters import IgnoreStateFilter
from eiogram.state import StateManager

from src.db import AsyncSession, UserMessage
from src.db import AsyncSession, UserMessage, User
from src.lang import Dialogs
from src.keys import BotKB, BotCB, AreaType, TaskType
from src.utils.depends import ClearState
Expand All @@ -13,10 +13,12 @@

@router.callback_query(BotCB.filter(area=AreaType.CLIENT, task=TaskType.MENU), IgnoreStateFilter())
async def clients_menu(
callback_query: CallbackQuery, callback_data: BotCB, db: AsyncSession, state: StateManager, _: ClearState
callback_query: CallbackQuery, callback_data: BotCB, db: AsyncSession, state: StateManager, _: ClearState, dbuser: User
):
if int(callback_data.target) != 0:
await state.upsert_context(db=db, client_id=callback_data.target)
if int(callback_data.target) not in dbuser.client_ids() and not dbuser.is_owner:
return await callback_query.answer(text="Access Denied", show_alert=True)
update = await callback_query.message.edit(
text=Dialogs.CLIENTS_MENU, reply_markup=BotKB.clients_menu(id=callback_data.target)
)
Expand Down
17 changes: 14 additions & 3 deletions src/handlers/clients/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from src.db import AsyncSession, Client, UserMessage
from src.keys import BotKB, BotCB, AreaType, TaskType, StepType
from src.lang import Dialogs
from src.utils.depends import ShouldBeOwner

router = Router()

Expand All @@ -18,7 +19,12 @@ class ClientUpdateForm(StateGroup):

@router.callback_query(BotCB.filter(area=AreaType.CLIENT, task=TaskType.UPDATE))
async def clients_update(
callback_query: CallbackQuery, callback_data: BotCB, db: AsyncSession, state: StateManager, state_data: dict
callback_query: CallbackQuery,
callback_data: BotCB,
db: AsyncSession,
state: StateManager,
state_data: dict,
__: ShouldBeOwner,
):
kb = BotKB.clients_back(id=state_data["client_id"])
match callback_data.step:
Expand All @@ -39,7 +45,7 @@ async def clients_update(


@router.message(StateFilter(ClientUpdateForm.input), Text())
async def input_handler(message: Message, db: AsyncSession, state: StateManager, state_data: dict):
async def input_handler(message: Message, db: AsyncSession, state: StateManager, state_data: dict, __: ShouldBeOwner):
client = await Client.get_by_id(db, state_data["client_id"])
if not client:
update = await message.answer(text=Dialogs.CLIENTS_NOT_FOUND, reply_markup=BotKB.home_back())
Expand Down Expand Up @@ -67,7 +73,12 @@ async def input_handler(message: Message, db: AsyncSession, state: StateManager,

@router.callback_query(StateFilter(ClientUpdateForm.approval), BotCB.filter(area=AreaType.CLIENT, task=TaskType.UPDATE))
async def approval_handler(
callback_query: CallbackQuery, callback_data: BotCB, db: AsyncSession, state: StateManager, state_data: dict
callback_query: CallbackQuery,
callback_data: BotCB,
db: AsyncSession,
state: StateManager,
state_data: dict,
__: ShouldBeOwner,
):
client = await Client.get_by_id(db, state_data["client_id"])
if not client:
Expand Down
11 changes: 6 additions & 5 deletions src/handlers/middlewares.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import logging
from typing import Any, Callable, Dict, Awaitable
from eiogram.middleware import BaseMiddleware
from eiogram.types import Update
Expand All @@ -20,9 +19,11 @@ async def __call__(
dbuser = await User.upsert(db, user=user)
if update.message:
await UserMessage.add(update.message)
if not dbuser.has_access:
logging.warning(f"User {dbuser.id} try to access admin panel")
return False
data["dbuser"] = dbuser
data["db"] = db
return await handler(update, data)
try:
return await handler(update, data)
except Exception as e:
if e == "Access Denied":
return
raise e
11 changes: 8 additions & 3 deletions src/handlers/primary_ips/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from src.db import AsyncSession, UserMessage
from src.keys import BotKB, BotCB, AreaType, TaskType
from src.lang import Dialogs
from src.utils.depends import GetHetzner
from src.utils.depends import GetHetzner, ShouldBeOwner

router = Router()

Expand All @@ -17,13 +17,17 @@ class PrimaryCreateForm(StateGroup):


@router.callback_query(BotCB.filter(area=AreaType.PRIMARY_IP, task=TaskType.CREATE))
async def primary_ips_create(callback_query: CallbackQuery, callback_data: BotCB, db: AsyncSession, state: StateManager):
async def primary_ips_create(
callback_query: CallbackQuery, callback_data: BotCB, db: AsyncSession, state: StateManager, __: ShouldBeOwner
):
await state.upsert_context(db=db, state=PrimaryCreateForm.remark, ip_type=callback_data.target)
return await callback_query.message.edit(text=Dialogs.PRIMARY_IPS_ENTER_REMARK, reply_markup=BotKB.primary_ips_back())


@router.message(StateFilter(PrimaryCreateForm.remark), Text())
async def remark_handler(message: Message, db: AsyncSession, state: StateManager, hetzner: GetHetzner, state_data: dict):
async def remark_handler(
message: Message, db: AsyncSession, state: StateManager, hetzner: GetHetzner, state_data: dict, __: ShouldBeOwner
):
datacenters = hetzner.datacenters.get_all()
if not datacenters:
update = await message.answer(text=Dialogs.PRIMARY_IPS_NO_DATACENTERS)
Expand All @@ -43,6 +47,7 @@ async def select_datacenter(
state: StateManager,
state_data: dict,
hetzner: GetHetzner,
__: ShouldBeOwner,
):
datacenter = hetzner.datacenters.get_by_id(int(callback_data.target))
if not datacenter:
Expand Down
6 changes: 4 additions & 2 deletions src/handlers/primary_ips/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
from src.db import UserMessage
from src.lang import Dialogs
from src.keys import BotCB, BotKB, AreaType, TaskType
from src.utils.depends import GetHetzner, ClearState
from src.utils.depends import GetHetzner, ClearState, ShouldBeOwner

router = Router()


@router.callback_query(BotCB.filter(area=AreaType.PRIMARY_IP, task=TaskType.INFO), IgnoreStateFilter())
async def primary_ips_info(callback_query: CallbackQuery, callback_data: BotCB, hetzner: GetHetzner, _: ClearState):
async def primary_ips_info(
callback_query: CallbackQuery, callback_data: BotCB, hetzner: GetHetzner, _: ClearState, __: ShouldBeOwner
):
primary_ip = hetzner.primary_ips.get_by_id(int(callback_data.target))
if not primary_ip:
return await callback_query.message.edit(text=Dialogs.PRIMARY_IPS_NOT_FOUND)
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/primary_ips/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
from src.db import UserMessage
from src.lang import Dialogs
from src.keys import BotKB, BotCB, AreaType, TaskType
from src.utils.depends import GetHetzner, ClearState
from src.utils.depends import GetHetzner, ClearState, ShouldBeOwner

router = Router()


@router.callback_query(BotCB.filter(area=AreaType.PRIMARY_IP, task=TaskType.MENU), IgnoreStateFilter())
async def primary_ips(callback_query: CallbackQuery, hetzner: GetHetzner, _: ClearState):
async def primary_ips(callback_query: CallbackQuery, hetzner: GetHetzner, _: ClearState, __: ShouldBeOwner, state_data: dict):
primary_ips = hetzner.primary_ips.get_all()
update = await callback_query.message.edit(
text=Dialogs.PRIMARY_IPS_MENU, reply_markup=BotKB.primary_ips_menu(primary_ips=primary_ips)
Expand Down
Loading