Skip to content

Commit f7d6f70

Browse files
committedMar 22, 2025
add file
1 parent 7754165 commit f7d6f70

File tree

7 files changed

+83
-101
lines changed

7 files changed

+83
-101
lines changed
 

‎auth.py

+18-39
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,23 @@
1-
import os
21
import logging
32
import hashlib
4-
from datetime import datetime
53
from typing import Optional, Dict
6-
7-
from fastapi import APIRouter, Depends, HTTPException, status, Form
4+
from fastapi import APIRouter, Depends, HTTPException, Form
85
from fastapi.security import OAuth2PasswordBearer
96
from sqlalchemy import select
107
from passlib.context import CryptContext
11-
128
from database import new_session, UserOrm, LinkOrm
139
from schemas import UserRegister, UserResponse
1410

1511
logger = logging.getLogger(__name__)
1612

17-
# Контекст для хэширования паролей
1813
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
1914

20-
# Схема OAuth2 (без токенов, просто для совместимости)
2115
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False)
2216

23-
# Хранилище авторизованных пользователей (в памяти)
2417
active_users: Dict[str, UserResponse] = {}
2518

26-
# Создаем роутер для аутентификации
27-
auth_router = APIRouter(prefix="/auth", tags=["Аутентификация"])
19+
auth_router = APIRouter(prefix="/auth",
20+
tags=["Аутентификация"])
2821

2922

3023
def generate_user_secret_key(username: str) -> str:
@@ -36,19 +29,16 @@ class AuthService:
3629
@classmethod
3730
async def register_user(cls, user_data: UserRegister) -> UserResponse:
3831
"""
39-
Регистрирует нового пользователя.
32+
Регистрация пользователя.
4033
"""
4134
async with new_session() as session:
4235
try:
43-
# Проверяем, существует ли пользователь
4436
existing_user = await session.execute(select(UserOrm).where(UserOrm.username == user_data.username))
4537
if existing_user.scalar():
4638
raise HTTPException(status_code=400, detail="Username already exists")
4739

48-
# Хэшируем пароль
4940
hashed_password = pwd_context.hash(user_data.password)
5041

51-
# Создаем нового пользователя
5242
user = UserOrm(username=user_data.username, password_hash=hashed_password)
5343
session.add(user)
5444
await session.flush()
@@ -61,10 +51,11 @@ async def register_user(cls, user_data: UserRegister) -> UserResponse:
6151
await session.rollback()
6252
raise HTTPException(status_code=500, detail="Internal Server Error")
6353

54+
6455
@classmethod
6556
async def authenticate_user(cls, username: str, password: str) -> UserOrm:
6657
"""
67-
Аутентифицирует пользователя.
58+
Аутентификация пользователя.
6859
"""
6960
async with new_session() as session:
7061
user = await session.execute(select(UserOrm).where(UserOrm.username == username))
@@ -73,16 +64,14 @@ async def authenticate_user(cls, username: str, password: str) -> UserOrm:
7364
raise HTTPException(status_code=401, detail="Invalid username or password")
7465
return user
7566

67+
7668
@classmethod
7769
async def get_current_user(cls, token: Optional[str] = Depends(oauth2_scheme)) -> Optional[UserResponse]:
78-
"""
79-
Получает текущего пользователя из хранилища активных пользователей.
80-
"""
70+
8171
if token is None:
8272
logger.debug("Токен отсутствует")
8373
return None
8474

85-
# Ищем пользователя в хранилище активных пользователей
8675
user = active_users.get(token)
8776
if user is None:
8877
logger.debug(f"Пользователь с токеном {token} не найден")
@@ -91,19 +80,18 @@ async def get_current_user(cls, token: Optional[str] = Depends(oauth2_scheme)) -
9180
logger.debug(f"Пользователь {user.username} успешно авторизован")
9281
return user
9382

83+
9484
@classmethod
9585
async def update_user_id_for_links(cls, username: str, user_id: int):
9686
"""
97-
Обновляет user_id для всех ссылок, созданных до авторизации.
87+
Обновление user_id для всех ссылок, созданных до авторизации.
9888
"""
9989
async with new_session() as session:
10090
try:
101-
# Находим все ссылки, созданные до авторизации (user_id = NULL)
10291
query = select(LinkOrm).where(LinkOrm.user_id.is_(None))
10392
result = await session.execute(query)
10493
links = result.scalars().all()
10594

106-
# Обновляем user_id для найденных ссылок
10795
for link in links:
10896
link.user_id = user_id
10997

@@ -115,41 +103,32 @@ async def update_user_id_for_links(cls, username: str, user_id: int):
115103
raise HTTPException(status_code=500, detail="Internal Server Error")
116104

117105

118-
# Эндпоинты для аутентификации
119106
@auth_router.post("/register")
120107
async def register(
121-
username: str = Form(...), # Ввод через форму
122-
password: str = Form(...), # Ввод через форму
108+
username: str = Form(...),
109+
password: str = Form(...),
123110
):
124-
"""
125-
Регистрирует нового пользователя (через форму).
126-
"""
111+
127112
user_data = UserRegister(username=username, password=password)
128113
return await AuthService.register_user(user_data)
129114

115+
130116
@auth_router.post("/token")
131117
async def login_for_access_token(
132-
username: str = Form(...), # Ввод через форму
133-
password: str = Form(...), # Ввод через форму
118+
username: str = Form(...),
119+
password: str = Form(...),
134120
):
135-
"""
136-
Аутентифицирует пользователя и возвращает токен (просто строку).
137-
Также обновляет user_id для всех ссылок, созданных до авторизации.
138-
"""
121+
139122
user = await AuthService.authenticate_user(username, password)
140123
user_response = UserResponse(id=user.id, username=user.username)
141124

142-
# Генерируем "токен" (просто хэш имени пользователя)
143125
token = generate_user_secret_key(username)
144126

145-
# Сохраняем пользователя в хранилище активных пользователей
146127
active_users[token] = user_response
147128

148-
# Обновляем user_id для всех ссылок, созданных до авторизации
149129
await AuthService.update_user_id_for_links(username, user.id)
150130

151131
return {"access_token": token, "token_type": "bearer"}
152132

153133

154-
# Экспортируем функцию для использования в других модулях
155-
get_current_user = AuthService.get_current_user
134+
get_current_user = AuthService.get_current_user

‎cache.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import redis
2-
from fastapi import HTTPException
32

43
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
54

‎database.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@
33
from datetime import datetime, timedelta
44
from typing import Optional
55

6+
67
engine = create_async_engine("sqlite+aiosqlite:///links.db")
78
new_session = async_sessionmaker(engine, expire_on_commit=False)
89

10+
911
class Model(DeclarativeBase):
1012
pass
1113

14+
1215
class UserOrm(Model):
1316
__tablename__ = "users"
1417

1518
id: Mapped[int] = mapped_column(primary_key=True)
1619
username: Mapped[str] = mapped_column(unique=True)
1720
password_hash: Mapped[str]
1821

22+
1923
class LinkOrm(Model):
2024
__tablename__ = "links"
2125

@@ -28,10 +32,12 @@ class LinkOrm(Model):
2832
click_count: Mapped[int] = mapped_column(default=0)
2933
last_used_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
3034

35+
3136
async def create_tables():
3237
async with engine.begin() as conn:
3338
await conn.run_sync(Model.metadata.create_all)
3439

40+
3541
async def delete_tables():
3642
async with engine.begin() as conn:
37-
await conn.run_sync(Model.metadata.drop_all)
43+
await conn.run_sync(Model.metadata.drop_all)

‎main.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from fastapi import FastAPI
22
from fastapi.openapi.utils import get_openapi
3-
from contextlib import asynccontextmanager
43
from database import create_tables, delete_tables
54
from router import router as links_router
65
from auth import auth_router
@@ -12,14 +11,14 @@
1211
logging.basicConfig(level=logging.DEBUG)
1312
logger = logging.getLogger(__name__)
1413

14+
1515
@asynccontextmanager
1616
async def lifespan(app: FastAPI):
1717
await delete_tables()
1818
logger.info("База очищена")
1919
await create_tables()
2020
logger.info("База готова к работе")
2121

22-
# Запускаем фоновую задачу
2322
asyncio.create_task(delete_expired_links())
2423

2524
yield
@@ -29,6 +28,7 @@ async def lifespan(app: FastAPI):
2928
app.include_router(links_router)
3029
app.include_router(auth_router)
3130

31+
3232
def custom_openapi():
3333
if app.openapi_schema:
3434
return app.openapi_schema

‎repository.py

+26-33
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,30 @@
1212

1313
logger = logging.getLogger(__name__)
1414

15+
1516
def normalize_url(url: str) -> str:
16-
"""
17-
Нормализует URL: декодирует и приводит к нижнему регистру.
18-
"""
1917
return unquote(url).lower().strip()
2018

19+
2120
class LinkRepository:
2221
@staticmethod
2322
def generate_short_code(length: int = 8) -> str:
2423
"""
25-
Генерирует уникальный короткий код.
24+
Генерация короткого кода для URL.
2625
"""
27-
chars = string.ascii_letters + string.digits # Буквы и цифры
26+
chars = string.ascii_letters + string.digits
2827
return ''.join(secrets.choice(chars) for _ in range(length))
2928

29+
3030
@classmethod
3131
async def add_one(cls, data: SLinkAdd, user_id: Optional[int] = None) -> SLinkResponse:
3232
"""
33-
Добавляет новую ссылку в базу данных.
34-
Поддерживает использование custom_alias и expires_at.
33+
Добавление новой ссылки в БД.
3534
"""
3635
async with new_session() as session:
3736
try:
38-
# Нормализуем оригинальный URL
3937
normalized_url = normalize_url(str(data.original_url))
4038

41-
# Если передан custom_alias, проверяем его уникальность
4239
if data.custom_alias:
4340
existing_link = await cls.find_by_short_code(data.custom_alias)
4441
if existing_link:
@@ -48,22 +45,19 @@ async def add_one(cls, data: SLinkAdd, user_id: Optional[int] = None) -> SLinkRe
4845
)
4946
short_code = data.custom_alias
5047
else:
51-
# Генерируем уникальный short_code, если custom_alias не передан
5248
while True:
5349
short_code = cls.generate_short_code()
5450
existing_link = await cls.find_by_short_code(short_code)
5551
if not existing_link:
5652
break
5753

58-
# Устанавливаем expires_at (по умолчанию 30 дней с текущего момента)
5954
expires_at = data.expires_at if data.expires_at else datetime.utcnow() + timedelta(days=30)
6055

61-
# Создаем новую ссылку
6256
link = LinkOrm(
63-
original_url=normalized_url, # Сохраняем нормализованный URL
57+
original_url=normalized_url,
6458
short_code=short_code,
65-
user_id=user_id, # user_id может быть None (анонимный пользователь)
66-
expires_at=expires_at, # Устанавливаем срок действия
59+
user_id=user_id,
60+
expires_at=expires_at,
6761
)
6862
session.add(link)
6963
await session.flush()
@@ -78,42 +72,42 @@ async def add_one(cls, data: SLinkAdd, user_id: Optional[int] = None) -> SLinkRe
7872
expires_at=link.expires_at,
7973
user_id=link.user_id,
8074
click_count=link.click_count,
81-
short_url=None, # Поле short_url не используется в этой ручке
75+
short_url=None,
8276
)
8377
except HTTPException as e:
84-
raise e # Пробрасываем HTTPException
78+
raise e
8579
except Exception as e:
8680
logger.error(f"Error adding link: {e}")
8781
await session.rollback()
8882
raise HTTPException(status_code=500, detail="Internal Server Error")
8983

84+
9085
@classmethod
9186
async def find_by_short_code(cls, short_code: str) -> LinkOrm:
9287
"""
93-
Ищет ссылку по короткому коду.
88+
Поиск по короткому коду.
9489
"""
9590
async with new_session() as session:
9691
query = select(LinkOrm).where(LinkOrm.short_code == short_code)
9792
result = await session.execute(query)
9893
return result.scalars().first()
9994

95+
10096
@classmethod
10197
async def find_by_original_url(cls, original_url: str) -> Optional[SLinkResponse]:
10298
"""
103-
Ищет ссылку по оригинальному URL и возвращает её в формате SLinkResponse.
99+
Поиск по оригинальному URL.
104100
"""
105101
async with new_session() as session:
106-
# Нормализуем URL
107102
normalized_url = normalize_url(original_url)
108-
logger.debug(f"Normalized URL: {normalized_url}") # Логируем нормализованный URL
103+
logger.debug(f"Normalized URL: {normalized_url}")
109104

110-
# Ищем ссылку по нормализованному URL
111105
query = select(LinkOrm).where(LinkOrm.original_url == normalized_url)
112106
result = await session.execute(query)
113107
link = result.scalars().first()
114108

115109
if link:
116-
logger.debug(f"Found link: {link.original_url}") # Логируем URL из базы данных
110+
logger.debug(f"Found link: {link.original_url}")
117111
else:
118112
logger.debug("Link not found")
119113

@@ -128,13 +122,14 @@ async def find_by_original_url(cls, original_url: str) -> Optional[SLinkResponse
128122
expires_at=link.expires_at,
129123
user_id=link.user_id,
130124
click_count=link.click_count,
131-
short_url=f"http://127.0.0.1:8000/links/{link.short_code}", # Формируем короткий URL
125+
short_url=f"http://127.0.0.1:8000/links/{link.short_code}",
132126
)
133127

128+
134129
@classmethod
135130
async def delete_by_short_code(cls, short_code: str, user_id: int):
136131
"""
137-
Удаляет ссылку по короткому коду.
132+
Удаление ссылки по короткому коду.
138133
"""
139134
async with new_session() as session:
140135
query = delete(LinkOrm).where(
@@ -143,24 +138,22 @@ async def delete_by_short_code(cls, short_code: str, user_id: int):
143138
await session.execute(query)
144139
await session.commit()
145140

141+
146142
@classmethod
147143
async def update_original_url(cls, short_code: str, new_url: str, user_id: int) -> LinkOrm:
148144
"""
149-
Обновляет оригинальный URL для ссылки и возвращает обновленную запись.
145+
Обновление оригинального URL.
150146
"""
151147
async with new_session() as session:
152148
try:
153-
# Нормализуем новый URL
154149
normalized_url = normalize_url(new_url)
155150

156-
# Обновляем оригинальный URL
157151
query = update(LinkOrm).where(
158152
(LinkOrm.short_code == short_code) & (LinkOrm.user_id == user_id)
159-
).values(original_url=normalized_url) # Сохраняем нормализованный URL
153+
).values(original_url=normalized_url)
160154
await session.execute(query)
161155
await session.commit()
162156

163-
# Получаем обновленную запись
164157
updated_link = await cls.find_by_short_code(short_code)
165158
if not updated_link:
166159
logger.error(f"Failed to fetch updated link: short_code={short_code}")
@@ -172,24 +165,25 @@ async def update_original_url(cls, short_code: str, new_url: str, user_id: int)
172165
await session.rollback()
173166
return None
174167

168+
175169
@classmethod
176170
async def increment_click_count(cls, link_id: int):
177171
"""
178-
Увеличивает счетчик переходов по ссылке.
172+
Счетчик переходов по ссылке.
179173
"""
180174
async with new_session() as session:
181175
query = update(LinkOrm).where(LinkOrm.id == link_id).values(click_count=LinkOrm.click_count + 1)
182176
await session.execute(query)
183177
await session.commit()
184178

179+
185180
async def delete_expired_links():
186181
"""
187182
Фоновая задача для удаления истекших ссылок.
188183
"""
189184
while True:
190185
async with new_session() as session:
191186
try:
192-
# Удаляем ссылки, у которых expires_at меньше текущего времени
193187
query = delete(LinkOrm).where(LinkOrm.expires_at < datetime.utcnow())
194188
await session.execute(query)
195189
await session.commit()
@@ -198,5 +192,4 @@ async def delete_expired_links():
198192
logger.error(f"Error deleting expired links: {e}")
199193
await session.rollback()
200194

201-
# Проверяем каждые 30 минут
202195
await asyncio.sleep(1800)

‎router.py

+17-19
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,32 @@
1616

1717
@router.get("/search", response_model=SLinkResponse)
1818
async def search_link_by_original_url(
19-
original_url: str, # Параметр запроса
20-
request: Request, # Для формирования короткого URL
19+
original_url: str,
20+
request: Request,
2121
):
2222
"""
2323
Поиск ссылки по оригинальному URL.
24-
Возвращает ссылку с short_code.
2524
"""
2625
link = await LinkRepository.find_by_original_url(original_url)
2726
if not link:
2827
raise HTTPException(status_code=404, detail="Ссылка не найдена")
2928

30-
# Обновляем short_url с учетом текущего хоста
3129
link.short_url = f"{request.base_url}links/{link.short_code}"
3230
return link
3331

32+
3433
@router.post("/shorten", response_model=SLinkResponse)
3534
async def shorten_link(
3635
request: Request,
3736
original_url: str = Form(...),
3837
custom_alias: Optional[str] = Form(None),
3938
expires_at: Optional[datetime] = Form(None),
40-
user: Optional[UserResponse] = Depends(get_current_user), # Опциональная зависимость
39+
user: Optional[UserResponse] = Depends(get_current_user),
4140
) -> SLinkResponse:
4241
"""
43-
Создает короткую ссылку для оригинального URL.
44-
Поддерживает использование custom_alias и expires_at.
42+
Создание короткой ссылки для оригинального URL.
4543
"""
4644
try:
47-
# Если пользователь авторизован, используем его user_id, иначе None
4845
user_id = user.id if user else None
4946
link_data = SLinkAdd(original_url=original_url, custom_alias=custom_alias, expires_at=expires_at)
5047
link = await LinkRepository.add_one(link_data, user_id=user_id)
@@ -59,15 +56,16 @@ async def shorten_link(
5956
short_url=f"{request.base_url}links/{link.short_code}",
6057
)
6158
except HTTPException as e:
62-
raise e # Пробрасываем HTTPException
59+
raise e
6360
except Exception as e:
6461
logger.error(f"Error creating link: {e}")
6562
raise HTTPException(status_code=500, detail="Internal Server Error")
6663

64+
6765
@router.get("/{short_code}")
6866
async def redirect_link(short_code: str):
6967
"""
70-
Перенаправляет на оригинальный URL по короткой ссылке.
68+
Перенаправление на оригинальный URL по короткой ссылке.
7169
"""
7270
link = await LinkRepository.find_by_short_code(short_code)
7371
if not link:
@@ -76,14 +74,14 @@ async def redirect_link(short_code: str):
7674
await LinkRepository.increment_click_count(link.id)
7775
return RedirectResponse(url=link.original_url)
7876

77+
7978
@router.delete("/{short_code}")
8079
async def delete_link(
8180
short_code: str,
8281
user: Optional[UserResponse] = Depends(get_current_user),
8382
):
8483
"""
85-
Удаляет короткую ссылку.
86-
Доступно только авторизованным пользователям, которые создали ссылку.
84+
Удаление короткой ссылки.
8785
"""
8886
if user is None:
8987
raise HTTPException(status_code=403, detail="Необходима авторизация для удаления ссылки")
@@ -98,15 +96,15 @@ async def delete_link(
9896
await LinkRepository.delete_by_short_code(short_code, user.id)
9997
return {"ok": True}
10098

99+
101100
@router.put("/{short_code}", response_model=SLinkResponse)
102101
async def update_link(
103-
short_code: str, # short_code берется из URL
104-
new_url: str = Form(...), # new_url берется из формы (x-www-form-urlencoded)
102+
short_code: str,
103+
new_url: str = Form(...),
105104
user: Optional[UserResponse] = Depends(get_current_user),
106105
) -> SLinkResponse:
107106
"""
108-
Обновляет оригинальный URL для короткой ссылки.
109-
Доступно только авторизованным пользователям, которые создали ссылку.
107+
Обновление оригинального URL для короткой ссылки.
110108
"""
111109
if user is None:
112110
raise HTTPException(status_code=403, detail="Необходима авторизация для изменения ссылки")
@@ -130,14 +128,14 @@ async def update_link(
130128
expires_at=updated_link.expires_at,
131129
user_id=updated_link.user_id,
132130
click_count=updated_link.click_count,
133-
short_url=None, # Поле short_url не используется в этой ручке
131+
short_url=None,
134132
)
135133

134+
136135
@router.get("/{short_code}/stats", response_model=SLinkStatsResponse)
137136
async def link_stats(short_code: str) -> SLinkStatsResponse:
138137
"""
139-
Возвращает статистику по короткой ссылке.
140-
Доступно всем.
138+
Статистика по короткой ссылке.
141139
"""
142140
link = await LinkRepository.find_by_short_code(short_code)
143141
if not link:

‎schemas.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,27 @@
22
from datetime import datetime
33
from typing import Optional
44

5+
56
class UserRegister(BaseModel):
67
username: str = Field(..., min_length=3, max_length=50)
78
password: str = Field(..., min_length=6)
89

10+
911
class UserLogin(BaseModel):
1012
username: str
1113
password: str
1214

15+
1316
class UserResponse(BaseModel):
1417
id: int
1518
username: str
1619

1720
class Config:
1821
from_attributes = True
1922

23+
2024
class SLinkAdd(BaseModel):
21-
original_url: str # Используем str вместо HttpUrl
25+
original_url: str
2226
custom_alias: Optional[str] = Field(
2327
None,
2428
min_length=4,
@@ -31,25 +35,28 @@ class SLinkAdd(BaseModel):
3135
description="Дата и время истечения срока действия ссылки (формат: YYYY-MM-DDTHH:MM)."
3236
)
3337

38+
3439
class SLinkResponse(BaseModel):
3540
id: int
3641
original_url: HttpUrl
3742
short_code: str
3843
created_at: datetime
3944
expires_at: datetime
40-
user_id: Optional[int] # ID пользователя, создавшего ссылку
45+
user_id: Optional[int]
4146
click_count: int
42-
short_url: Optional[str] # Короткий URL
47+
short_url: Optional[str]
4348

4449
class Config:
4550
from_attributes = True
4651

52+
4753
class SLinkStatsResponse(BaseModel):
48-
original_url: HttpUrl # Оригинальный URL
49-
created_at: datetime # Дата создания
50-
click_count: int # Количество переходов
54+
original_url: HttpUrl
55+
created_at: datetime
56+
click_count: int
5157
last_used_at: datetime
5258

59+
5360
class Token(BaseModel):
5461
access_token: str
5562
token_type: str

0 commit comments

Comments
 (0)
Please sign in to comment.