Skip to content

Commit 13bdc24

Browse files
committed
Merge remote-tracking branch 'origin/master'
2 parents 6005942 + 723912f commit 13bdc24

File tree

10 files changed

+493
-133
lines changed

10 files changed

+493
-133
lines changed

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ APP_CORS_DOMAINS='["http://localhost:3000"]'
1818
APP_CACHE_TTL=300
1919
APP_CACHE_SIZE_LIMIT=1000
2020
APP_MIN_STORAGE_MB=1024
21-
TRUSTED_HOSTS='["127.0.0.1", 'devbin_frontend']'
21+
APP_TRUSTED_HOSTS='["127.0.0.1", 'devbin_frontend']'
22+
APP_KEEP_DELETED_PASTES_TIME_HOURS=336 # 2 Weeks
2223

2324
# Debug / Development settings
2425
APP_SQLALCHEMY_ECHO=false
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: 48 additions & 0 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

@@ -47,6 +54,34 @@ def validate_expires_at(cls, v):
4754
return v
4855

4956

57+
class EditPaste(BaseModel):
58+
title: str | None = Field(
59+
None,
60+
min_length=1,
61+
max_length=255,
62+
description="The title of the paste",
63+
)
64+
content: str | None = Field(
65+
None,
66+
min_length=1,
67+
max_length=config.MAX_CONTENT_LENGTH,
68+
description="The content of the paste",
69+
)
70+
content_language: PasteContentLanguage | None = Field(
71+
None,
72+
description="The language of the content",
73+
examples=[PasteContentLanguage.plain_text],
74+
)
75+
expires_at: datetime | None | _Unset = Field(
76+
default=UNSET,
77+
description="The expiration datetime. Explicitly set to null to remove expiration.",
78+
)
79+
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+
84+
5085
class PasteResponse(BaseModel):
5186
id: UUID4 = Field(
5287
description="The unique identifier of the paste",
@@ -67,6 +102,19 @@ class PasteResponse(BaseModel):
67102
created_at: datetime = Field(
68103
description="The creation timestamp of the paste",
69104
)
105+
last_updated_at: datetime | None = Field(
106+
description="The last time the paste was updated (null = never)",
107+
)
108+
109+
110+
class CreatePasteResponse(PasteResponse):
111+
edit_token: str = Field(
112+
description="The token to edit the paste",
113+
)
114+
115+
delete_token: str = Field(
116+
description="The token to delete the paste",
117+
)
70118

71119

72120
class LegacyPasteResponse(BaseModel):

backend/app/api/subroutes/pastes.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
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
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
911

1012
from app.api.dto.Error import ErrorResponse
11-
from app.api.dto.paste_dto import CreatePaste, LegacyPasteResponse, PasteResponse
13+
from app.api.dto.paste_dto import (
14+
CreatePaste,
15+
CreatePasteResponse,
16+
EditPaste,
17+
LegacyPasteResponse,
18+
PasteResponse,
19+
)
1220
from app.config import config
1321
from app.containers import Container
1422
from app.ratelimit import get_ip_address, limiter
@@ -21,6 +29,9 @@
2129
max_size=config.CACHE_SIZE_LIMIT,
2230
)
2331

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+
2435

2536
def get_exempt_key(request: Request) -> str:
2637
auth_header = request.headers.get("Authorization")
@@ -123,7 +134,7 @@ async def get_paste(
123134
)
124135

125136

126-
@pastes_route.post("")
137+
@pastes_route.post("", response_model=CreatePasteResponse)
127138
@limiter.limit("4/minute", key_func=get_exempt_key)
128139
@inject
129140
async def create_paste(
@@ -134,3 +145,46 @@ async def create_paste(
134145
return await paste_service.create_paste(
135146
create_paste_body, request.state.user_metadata
136147
)
148+
149+
150+
@pastes_route.put("/{paste_id}")
151+
@limiter.limit("4/minute", key_func=get_exempt_key)
152+
@inject
153+
async def edit_paste(
154+
request: Request,
155+
paste_id: UUID4,
156+
edit_paste_body: EditPaste,
157+
edit_token: str = Security(edit_token_key_header),
158+
paste_service: PasteService = Depends(Provide[Container.paste_service]),
159+
):
160+
result = await paste_service.edit_paste(paste_id, edit_paste_body, edit_token)
161+
if not result:
162+
raise HTTPException(
163+
status_code=404,
164+
detail=ErrorResponse(
165+
error="paste_not_found",
166+
message=f"Paste {paste_id} not found",
167+
).model_dump(),
168+
)
169+
return result
170+
171+
172+
@pastes_route.delete("/{paste_id}")
173+
@limiter.limit("4/minute", key_func=get_exempt_key)
174+
@inject
175+
async def delete_paste(
176+
request: Request,
177+
paste_id: UUID4,
178+
delete_token: str = Security(delete_token_key_header),
179+
paste_service: PasteService = Depends(Provide[Container.paste_service]),
180+
):
181+
result = await paste_service.delete_paste(paste_id, delete_token)
182+
if not result:
183+
raise HTTPException(
184+
status_code=404,
185+
detail=ErrorResponse(
186+
error="paste_not_found",
187+
message=f"Paste {paste_id} not found",
188+
).model_dump(),
189+
)
190+
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)