Skip to content

Commit c4a61e2

Browse files
committed
# Commit Message
feat: implement cleanup service for expired and deleted pastes - Add `CleanupService` to handle removal of expired and deleted pastes - Introduce `KEEP_DELETED_PASTES_TIME_HOURS` configuration option - Implement database and file cleanup for expired pastes - Implement database and file cleanup for deleted pastes beyond threshold - Add proper logging for cleanup operations - Update main.py to initialize and start cleanup worker - Add EditPaste DTO and update PasteResponse with last_updated_at - Add CreatePasteResponse with edit_token and delete_token fields
1 parent 52a7786 commit c4a61e2

File tree

9 files changed

+454
-132
lines changed

9 files changed

+454
-132
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Add Delete, Edit tokens and deleted at
2+
3+
Revision ID: 08393764144d
4+
Revises: 7c45e2617d61
5+
Create Date: 2025-12-17 22:00:02.736745
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '08393764144d'
16+
down_revision: Union[str, Sequence[str], None] = '7c45e2617d61'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.add_column('pastes', sa.Column('edit_token', sa.String(), nullable=True))
25+
op.add_column('pastes', sa.Column('last_updated_at', sa.TIMESTAMP(timezone=True), nullable=True))
26+
op.add_column('pastes', sa.Column('delete_token', sa.String(), nullable=True))
27+
op.add_column('pastes', sa.Column('deleted_at', sa.TIMESTAMP(timezone=True), nullable=True))
28+
# ### end Alembic commands ###
29+
30+
31+
def downgrade() -> None:
32+
"""Downgrade schema."""
33+
# ### commands auto generated by Alembic - please adjust! ###
34+
op.drop_column('pastes', 'deleted_at')
35+
op.drop_column('pastes', 'delete_token')
36+
op.drop_column('pastes', 'last_updated_at')
37+
op.drop_column('pastes', 'edit_token')
38+
# ### end Alembic commands ###

backend/app/api/dto/paste_dto.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@ def validate_expires_at(cls, v):
4747
return v
4848

4949

50+
class EditPaste(BaseModel):
51+
title: str = Field(
52+
min_length=1,
53+
max_length=255,
54+
description="The title of the paste",
55+
)
56+
content: str = Field(
57+
min_length=1,
58+
max_length=config.MAX_CONTENT_LENGTH,
59+
description="The content of the paste",
60+
)
61+
content_language: PasteContentLanguage = Field(
62+
description="The language of the content",
63+
examples=[PasteContentLanguage.plain_text],
64+
)
65+
expires_at: datetime | None = Field()
66+
67+
edit_token: str = Field(
68+
description="The token to edit the paste",
69+
)
70+
71+
5072
class PasteResponse(BaseModel):
5173
id: UUID4 = Field(
5274
description="The unique identifier of the paste",
@@ -67,6 +89,19 @@ class PasteResponse(BaseModel):
6789
created_at: datetime = Field(
6890
description="The creation timestamp of the paste",
6991
)
92+
last_updated_at: datetime | None = Field(
93+
description="The last time the paste was updated (null = never)",
94+
)
95+
96+
97+
class CreatePasteResponse(PasteResponse):
98+
edit_token: str = Field(
99+
description="The token to edit the paste",
100+
)
101+
102+
delete_token: str = Field(
103+
description="The token to delete the paste",
104+
)
70105

71106

72107
class LegacyPasteResponse(BaseModel):

backend/app/api/subroutes/pastes.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
from aiocache.serializers import PickleSerializer
44
from dependency_injector.wiring import Provide, inject
5-
from fastapi import APIRouter, Depends
5+
from fastapi import APIRouter, Depends, Header, HTTPException
66
from pydantic import UUID4
77
from starlette.requests import Request
88
from starlette.responses import Response
99

1010
from app.api.dto.Error import ErrorResponse
11-
from app.api.dto.paste_dto import CreatePaste, LegacyPasteResponse, PasteResponse
11+
from app.api.dto.paste_dto import (
12+
CreatePaste,
13+
CreatePasteResponse,
14+
EditPaste,
15+
LegacyPasteResponse,
16+
PasteResponse,
17+
)
1218
from app.config import config
1319
from app.containers import Container
1420
from app.ratelimit import get_ip_address, limiter
@@ -123,7 +129,7 @@ async def get_paste(
123129
)
124130

125131

126-
@pastes_route.post("")
132+
@pastes_route.post("", response_model=CreatePasteResponse)
127133
@limiter.limit("4/minute", key_func=get_exempt_key)
128134
@inject
129135
async def create_paste(
@@ -134,3 +140,45 @@ async def create_paste(
134140
return await paste_service.create_paste(
135141
create_paste_body, request.state.user_metadata
136142
)
143+
144+
145+
@pastes_route.put("/{paste_id}")
146+
@limiter.limit("4/minute", key_func=get_exempt_key)
147+
@inject
148+
async def edit_paste(
149+
request: Request,
150+
paste_id: UUID4,
151+
edit_paste_body: EditPaste,
152+
paste_service: PasteService = Depends(Provide[Container.paste_service]),
153+
):
154+
result = await paste_service.edit_paste(paste_id, edit_paste_body)
155+
if not result:
156+
raise HTTPException(
157+
status_code=404,
158+
detail=ErrorResponse(
159+
error="paste_not_found",
160+
message=f"Paste {paste_id} not found",
161+
).model_dump(),
162+
)
163+
return result
164+
165+
166+
@pastes_route.delete("/{paste_id}")
167+
@limiter.limit("4/minute", key_func=get_exempt_key)
168+
@inject
169+
async def delete_paste(
170+
request: Request,
171+
paste_id: UUID4,
172+
delete_token: str = Header(...),
173+
paste_service: PasteService = Depends(Provide[Container.paste_service]),
174+
):
175+
result = await paste_service.delete_paste(paste_id, delete_token)
176+
if not result:
177+
raise HTTPException(
178+
status_code=404,
179+
detail=ErrorResponse(
180+
error="paste_not_found",
181+
message=f"Paste {paste_id} not found",
182+
).model_dump(),
183+
)
184+
return {"message": "Paste deleted successfully"}

backend/app/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ class Config(BaseSettings):
4343
description="Minimum storage size in MB free",
4444
)
4545

46+
KEEP_DELETED_PASTES_TIME_HOURS: int = Field(
47+
default=336,
48+
validation_alias="APP_KEEP_DELETED_PASTES_TIME_HOURS",
49+
description="Keep deleted pastes for X hours ( Default 336 hours, 2 weeks, -1 disable, 0 instant )",
50+
)
51+
4652
TRUSTED_HOSTS: list[str] = Field(
4753
default=["127.0.0.1"],
4854
validation_alias="APP_TRUSTED_HOSTS",

backend/app/containers.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212

1313
@asynccontextmanager
14-
async def _engine_resource(db_url: str, echo: bool = False) -> AsyncIterator[AsyncEngine]:
14+
async def _engine_resource(
15+
db_url: str, echo: bool = False
16+
) -> AsyncIterator[AsyncEngine]:
1517
engine = create_async_engine(db_url, echo=echo, future=True)
1618
try:
1719
yield engine
@@ -29,12 +31,14 @@ async def _session_resource(factory: sessionmaker) -> AsyncIterator[AsyncSession
2931

3032

3133
class Container(containers.DeclarativeContainer):
32-
wiring_config = containers.WiringConfiguration(modules=[
33-
"app.api.routes",
34-
"app.api.subroutes.pastes",
35-
"app.services",
36-
"app.dependencies.db",
37-
])
34+
wiring_config = containers.WiringConfiguration(
35+
modules=[
36+
"app.api.routes",
37+
"app.api.subroutes.pastes",
38+
"app.services",
39+
"app.dependencies.db",
40+
]
41+
)
3842

3943
config = providers.Callable(get_config)
4044
# Database engine (async) as a managed resource
@@ -54,14 +58,16 @@ class Container(containers.DeclarativeContainer):
5458
)
5559

5660
# Services
57-
from app.services.health_service import (
58-
HealthService, # local import to avoid cycles during tooling
59-
)
61+
from app.services.cleanup_service import CleanupService
62+
from app.services.health_service import HealthService
6063
from app.services.paste_service import PasteService
64+
6165
health_service = providers.Factory(HealthService, session_factory)
6266

67+
cleanup_service = providers.Factory(
68+
CleanupService, session_factory, config().BASE_FOLDER_PATH
69+
)
70+
6371
paste_service = providers.Factory(
64-
PasteService,
65-
session_factory,
66-
config().BASE_FOLDER_PATH
72+
PasteService, session_factory, cleanup_service, config().BASE_FOLDER_PATH
6773
)

backend/app/db/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,21 @@ class PasteEntity(Base):
1515
content_path = Column(String, nullable=False)
1616
content_language = Column(String, nullable=False, server_default="plain_text")
1717
expires_at = Column(TIMESTAMP(timezone=True), nullable=True)
18-
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
18+
created_at = Column(
19+
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
20+
)
1921

2022
content_size = Column(Integer, nullable=False)
2123

2224
creator_ip = Column(String)
2325
creator_user_agent = Column(String)
2426

27+
edit_token = Column(String)
28+
last_updated_at = Column(TIMESTAMP(timezone=True))
29+
30+
delete_token = Column(String)
31+
deleted_at = Column(TIMESTAMP(timezone=True), nullable=True)
32+
2533
def __repr__(self):
2634
return f"<Paste(id={self.id}, title='{self.title}')>"
2735

0 commit comments

Comments
 (0)