Skip to content

Commit 48656e1

Browse files
committed
feat: enhance paste editing with token authentication and partial updates
1 parent 4b2193f commit 48656e1

File tree

4 files changed

+67
-30
lines changed

4 files changed

+67
-30
lines changed

backend/app/api/dto/paste_dto.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
from app.config import config
88

99

10+
class _Unset(BaseModel):
11+
pass
12+
13+
14+
UNSET = _Unset()
15+
16+
1017
class PasteContentLanguage(str, Enum):
1118
plain_text = "plain_text"
1219

@@ -48,26 +55,32 @@ def validate_expires_at(cls, v):
4855

4956

5057
class EditPaste(BaseModel):
51-
title: str = Field(
58+
title: str | None = Field(
59+
None,
5260
min_length=1,
5361
max_length=255,
5462
description="The title of the paste",
5563
)
56-
content: str = Field(
64+
content: str | None = Field(
65+
None,
5766
min_length=1,
5867
max_length=config.MAX_CONTENT_LENGTH,
5968
description="The content of the paste",
6069
)
61-
content_language: PasteContentLanguage = Field(
70+
content_language: PasteContentLanguage | None = Field(
71+
None,
6272
description="The language of the content",
6373
examples=[PasteContentLanguage.plain_text],
6474
)
65-
expires_at: datetime | None = Field()
66-
67-
edit_token: str = Field(
68-
description="The token to edit the paste",
75+
expires_at: datetime | None | _Unset = Field(
76+
default=UNSET,
77+
description="The expiration datetime. Explicitly set to null to remove expiration.",
6978
)
7079

80+
def is_expires_at_set(self) -> bool:
81+
"""Check if expires_at was explicitly provided (including None)."""
82+
return not isinstance(self.expires_at, _Unset)
83+
7184

7285
class PasteResponse(BaseModel):
7386
id: UUID4 = Field(

backend/app/api/subroutes/pastes.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from aiocache.serializers import PickleSerializer
44
from dependency_injector.wiring import Provide, inject
55
from fastapi import APIRouter, Depends, Header, HTTPException
6+
from fastapi.params import Security
7+
from fastapi.security import APIKeyHeader
68
from pydantic import UUID4
79
from starlette.requests import Request
810
from starlette.responses import Response
@@ -27,6 +29,9 @@
2729
max_size=config.CACHE_SIZE_LIMIT,
2830
)
2931

32+
edit_token_key_header = APIKeyHeader(name="Authorization", scheme_name="Edit Token")
33+
delete_token_key_header = APIKeyHeader(name="Authorization", scheme_name="Delete Token")
34+
3035

3136
def get_exempt_key(request: Request) -> str:
3237
auth_header = request.headers.get("Authorization")
@@ -149,9 +154,10 @@ async def edit_paste(
149154
request: Request,
150155
paste_id: UUID4,
151156
edit_paste_body: EditPaste,
157+
edit_token: str = Security(edit_token_key_header),
152158
paste_service: PasteService = Depends(Provide[Container.paste_service]),
153159
):
154-
result = await paste_service.edit_paste(paste_id, edit_paste_body)
160+
result = await paste_service.edit_paste(paste_id, edit_paste_body, edit_token)
155161
if not result:
156162
raise HTTPException(
157163
status_code=404,
@@ -169,7 +175,7 @@ async def edit_paste(
169175
async def delete_paste(
170176
request: Request,
171177
paste_id: UUID4,
172-
delete_token: str = Header(...),
178+
delete_token: str = Security(delete_token_key_header),
173179
paste_service: PasteService = Depends(Provide[Container.paste_service]),
174180
):
175181
result = await paste_service.delete_paste(paste_id, delete_token)

backend/app/services/cleanup_service.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@ def __init__(
2727
def start_cleanup_worker(self):
2828
"""Start the background cleanup worker"""
2929
self.logger.info("Starting cleanup worker")
30-
if self._cleanup_task is not None or self._lock_file.exists():
30+
if self._cleanup_task is not None:
3131
return # Already running
32-
_ = self._acquire_lock()
32+
3333
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
3434
self.logger.info("Background cleanup worker started")
3535

3636
async def stop_cleanup_worker(self):
3737
"""Stop the background cleanup worker"""
3838
if self._cleanup_task is not None:
39-
_ = self._cleanup_task.cancel()
39+
self._cleanup_task.cancel()
4040
try:
4141
await self._cleanup_task
4242
except asyncio.CancelledError:
@@ -46,7 +46,11 @@ async def stop_cleanup_worker(self):
4646
self.logger.info("Background cleanup worker stopped")
4747

4848
async def _cleanup_loop(self):
49-
"""Main cleanup loop that runs every 10 minutes"""
49+
"""Main cleanup loop that runs every 5 minutes"""
50+
# Wait for 5 minutes and retry
51+
while not self._acquire_lock():
52+
await asyncio.sleep(300)
53+
5054
while True:
5155
self._touch_lock()
5256
try:
@@ -58,7 +62,7 @@ async def _cleanup_loop(self):
5862
await self._cleanup_deleted_pastes()
5963

6064
# Wait 5 minutes before next run
61-
await asyncio.sleep(300)
65+
await asyncio.sleep(600)
6266
except Exception as exc:
6367
self.logger.error("Error in cleanup loop: %s", exc)
6468
await asyncio.sleep(60) # Retry after 1 minute on error

backend/app/services/paste_service.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,14 @@ async def get_paste_by_id(self, paste_id: UUID4) -> PasteResponse | None:
150150
)
151151

152152
async def edit_paste(
153-
self, paste_id: UUID4, edit_paste: EditPaste
153+
self, paste_id: UUID4, edit_paste: EditPaste, edit_token: str
154154
) -> PasteResponse | None:
155155
async with self.session_maker() as session:
156156
stmt = (
157157
select(PasteEntity)
158158
.where(
159159
PasteEntity.id == paste_id,
160-
PasteEntity.edit_token == edit_paste.edit_token,
160+
PasteEntity.edit_token == edit_token,
161161
or_(
162162
PasteEntity.expires_at > datetime.now(tz=timezone.utc),
163163
PasteEntity.expires_at.is_(None),
@@ -170,27 +170,41 @@ async def edit_paste(
170170
).scalar_one_or_none()
171171
if result is None:
172172
return None
173-
content = edit_paste.content
174173

175-
# Update content file
176-
new_content_path = await self._save_content(
177-
str(paste_id),
178-
content,
179-
)
180-
if not new_content_path:
181-
return None
174+
# Update only the fields that are provided (not None)
175+
if (
176+
edit_paste.title is not None
177+
): # Using ellipsis as sentinel for "not provided"
178+
result.title = edit_paste.title
179+
if edit_paste.content_language is not None:
180+
result.content_language = edit_paste.content_language.value
181+
if edit_paste.is_expires_at_set():
182+
result.expires_at = edit_paste.expires_at
183+
184+
# Handle content update separately
185+
if edit_paste.content is not None:
186+
new_content_path = await self._save_content(
187+
str(paste_id), edit_paste.content
188+
)
189+
if not new_content_path:
190+
return None
191+
result.content_path = new_content_path
192+
result.content_size = len(edit_paste.content)
182193

183-
# Update database entity
184-
result.title = edit_paste.title
185-
result.content_language = edit_paste.content_language.value
186-
result.expires_at = edit_paste.expires_at
187-
result.content_path = new_content_path
188-
result.content_size = len(content)
189194
result.last_updated_at = datetime.now(tz=timezone.utc)
190195

191196
await session.commit()
192197
await session.refresh(result)
193198

199+
# Re-read content if updated
200+
content = (
201+
edit_paste.content
202+
if edit_paste.content is not None
203+
else await self._read_content(
204+
path.join(self.paste_base_folder_path, result.content_path)
205+
)
206+
)
207+
194208
return PasteResponse(
195209
id=result.id,
196210
title=result.title,

0 commit comments

Comments
 (0)