Skip to content

Commit 0857063

Browse files
committed
feat: link pastes to user accounts with ownership tracking
Add user_id FK to pastes table so authenticated users' pastes are associated with their account. Adds GET /pastes/me endpoint to retrieve a user's own pastes.
1 parent 0f2e904 commit 0857063

6 files changed

Lines changed: 117 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ Full reference: [`docs/configuration.md`](docs/configuration.md)
5353
|----------|-------------|
5454
| `GET /health` | Health check |
5555
| `GET /metrics` | Prometheus metrics |
56-
| `POST /pastes` | Create paste |
56+
| `POST /pastes` | Create paste (linked to account if authenticated) |
5757
| `GET /pastes/{id}` | Get paste |
58+
| `GET /pastes/me` | Get authenticated user's pastes |
5859
| `DELETE /pastes/{id}` | Delete paste |
5960
| `POST /auth/register` | Register a new user |
6061
| `POST /auth/login` | Authenticate and get tokens |
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Add user_id to pastes
2+
3+
Revision ID: add_user_id_to_pastes
4+
Revises: add_auth_tables
5+
Create Date: 2026-04-04
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "add_user_id_to_pastes"
17+
down_revision: str | None = "add_auth_tables"
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
22+
def upgrade() -> None:
23+
op.add_column("pastes", sa.Column("user_id", sa.UUID(as_uuid=True), nullable=True))
24+
op.create_foreign_key(
25+
"fk_pastes_user_id",
26+
"pastes",
27+
"users",
28+
["user_id"],
29+
["id"],
30+
ondelete="SET NULL",
31+
)
32+
op.create_index("idx_pastes_user_id", "pastes", ["user_id"])
33+
34+
35+
def downgrade() -> None:
36+
op.drop_index("idx_pastes_user_id", table_name="pastes")
37+
op.drop_constraint("fk_pastes_user_id", "pastes", type_="foreignkey")
38+
op.drop_column("pastes", "user_id")

backend/app/api/dto/paste_dto.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ class PasteResponse(BaseModel):
8282
id: UUID4 = Field(
8383
description="The unique identifier of the paste",
8484
)
85+
user_id: UUID4 | None = Field(
86+
None,
87+
description="The ID of the user who created the paste (null if anonymous)",
88+
)
8589
title: str = Field(
8690
description="The title of the paste",
8791
)

backend/app/api/subroutes/pastes.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
)
2121
from app.config import config
2222
from app.containers import Container
23-
from app.dependencies.auth import get_optional_current_user
23+
from app.dependencies.auth import get_current_user, get_optional_current_user
2424
from app.exceptions import PasteNotFoundError
2525
from app.ratelimit import create_auth_aware_key_func, create_auth_aware_limit_resolver, create_limit_resolver, limiter
2626
from app.services.paste_service import PasteService
@@ -58,6 +58,23 @@ async def _resolve_optional_user(
5858
delete_token_key_header = APIKeyHeader(name="Authorization", scheme_name="Delete Token")
5959

6060

61+
@pastes_route.get(
62+
"/me",
63+
response_model=list[PasteResponse],
64+
summary="Get pastes for the authenticated user",
65+
description="Retrieve all pastes created by the currently authenticated user.",
66+
)
67+
@limiter.limit(create_limit_resolver(config, "get_paste"), key_func=lambda r: ratelimit.get_exempt_key(r))
68+
@inject
69+
async def get_user_pastes(
70+
request: Request,
71+
paste_service: PasteService = Depends(Provide[Container.paste_service]),
72+
current_user=Depends(get_current_user),
73+
):
74+
"""Get all pastes belonging to the authenticated user."""
75+
return await paste_service.get_user_pastes(current_user.id)
76+
77+
6178
@pastes_route.get(
6279
"/legacy/{paste_id}",
6380
responses={404: {"model": ErrorResponse}, 200: {"model": LegacyPasteResponse}},
@@ -212,7 +229,12 @@ async def create_paste(
212229
_current_user=Depends(_resolve_optional_user),
213230
):
214231
"""Create a new paste and return edit/delete tokens."""
215-
return await paste_service.create_paste(create_paste_body, request.state.user_metadata)
232+
user = request.state.current_user
233+
return await paste_service.create_paste(
234+
create_paste_body,
235+
request.state.user_metadata,
236+
user_id=user.id if user else None,
237+
)
216238

217239

218240
@pastes_route.put(

backend/app/db/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class PasteEntity(Base):
2323
Index("idx_pastes_expires_at", "expires_at"),
2424
Index("idx_pastes_deleted_at", "deleted_at"),
2525
Index("idx_pastes_created_at", "created_at"),
26+
Index("idx_pastes_user_id", "user_id"),
2627
)
2728

2829
id = Column(UUID(as_uuid=True), primary_key=True, server_default=UUID_DEFAULT)
@@ -47,6 +48,12 @@ class PasteEntity(Base):
4748
delete_token = Column(String)
4849
deleted_at = Column(TIMESTAMP(timezone=True), nullable=True)
4950

51+
user_id = Column(
52+
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
53+
)
54+
55+
user = relationship("UserEntity", back_populates="pastes")
56+
5057
def __repr__(self):
5158
return f"<Paste(id={self.id}, title='{self.title}')>"
5259

@@ -95,6 +102,7 @@ class UserEntity(Base):
95102
refresh_tokens = relationship(
96103
"RefreshTokenEntity", back_populates="user", cascade="all, delete-orphan"
97104
)
105+
pastes = relationship("PasteEntity", back_populates="user")
98106

99107
def __repr__(self):
100108
return f"<User(id={self.id}, username='{self.username}')>"

backend/app/services/paste_service.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ async def get_paste_by_id(self, paste_id: UUID4) -> PasteResponse | None:
240240
paste_operations.labels(operation="get", status="success").inc()
241241
return PasteResponse(
242242
id=result.id,
243+
user_id=result.user_id,
243244
title=result.title,
244245
content=content,
245246
content_language=PasteContentLanguage(result.content_language),
@@ -326,6 +327,7 @@ async def edit_paste(self, paste_id: UUID4, edit_paste: EditPaste, edit_token: s
326327
paste_operations.labels(operation="edit", status="success").inc()
327328
return PasteResponse(
328329
id=result.id,
330+
user_id=result.user_id,
329331
title=result.title,
330332
content=content,
331333
content_language=PasteContentLanguage(result.content_language),
@@ -380,7 +382,7 @@ async def delete_paste(self, paste_id: UUID4, delete_token: str) -> bool:
380382
counter.dec()
381383
return True
382384

383-
async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> PasteResponse:
385+
async def create_paste(self, paste: CreatePaste, user_data: UserMetaData, user_id: uuid.UUID | None = None) -> PasteResponse:
384386
if not self.verify_storage_limit():
385387
paste_operations.labels(operation="create", status="storage_limit").inc()
386388
raise HTTPException(
@@ -426,6 +428,7 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas
426428
original_size=original_size,
427429
edit_token=edit_token_hashed,
428430
delete_token=delete_token_hashed,
431+
user_id=user_id,
429432
)
430433
session.add(entity)
431434
await session.commit()
@@ -442,6 +445,7 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas
442445

443446
return CreatePasteResponse(
444447
id=entity.id,
448+
user_id=entity.user_id,
445449
title=entity.title,
446450
content=paste.content,
447451
content_language=PasteContentLanguage(entity.content_language),
@@ -460,3 +464,39 @@ async def create_paste(self, paste: CreatePaste, user_data: UserMetaData) -> Pas
460464
detail="Failed to create paste",
461465
headers={"Retry-After": "60"},
462466
) from exc
467+
468+
async def get_user_pastes(self, user_id: uuid.UUID) -> list[PasteResponse]:
469+
async with self.session_maker() as session:
470+
stmt = (
471+
select(PasteEntity)
472+
.where(
473+
PasteEntity.user_id == user_id,
474+
PasteEntity.deleted_at.is_(None),
475+
or_(
476+
PasteEntity.expires_at > datetime.now(tz=UTC),
477+
PasteEntity.expires_at.is_(None),
478+
),
479+
)
480+
.order_by(PasteEntity.created_at.desc())
481+
)
482+
results = (await session.execute(stmt)).scalars().all()
483+
484+
pastes = []
485+
for result in results:
486+
content = await self._read_content(
487+
result.content_path,
488+
is_compressed=result.is_compressed,
489+
)
490+
pastes.append(
491+
PasteResponse(
492+
id=result.id,
493+
user_id=result.user_id,
494+
title=result.title,
495+
content=content,
496+
content_language=PasteContentLanguage(result.content_language),
497+
created_at=result.created_at,
498+
expires_at=result.expires_at,
499+
last_updated_at=result.last_updated_at,
500+
)
501+
)
502+
return pastes

0 commit comments

Comments
 (0)