Skip to content

Commit

Permalink
rework uploader into posts
Browse files Browse the repository at this point in the history
  • Loading branch information
kheina committed Dec 14, 2024
1 parent 2aa85eb commit 6a1f7b8
Show file tree
Hide file tree
Showing 21 changed files with 289 additions and 341 deletions.
4 changes: 4 additions & 0 deletions db/08/00-post-immutable-cols.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
drop trigger if exists immutable_columns on public.posts;

create trigger immutable_columns before update on public.posts
for each row execute procedure immutable_columns('post_id');
2 changes: 1 addition & 1 deletion k8s.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ spec:
spec:
containers:
- name: fuzzly-backend
image: us-central1-docker.pkg.dev/kheinacom/fuzzly-repo/fuzzly-backend@sha256:eb32ccd813dd607319a51bbf960fc5313484aa1c63678d3e67e9ff40f9881045
image: us-central1-docker.pkg.dev/kheinacom/fuzzly-repo/fuzzly-backend@sha256:040bba2764a1d6ab6ea514598667a58be7c607eff930838c0f000faba5c8650f
env:
- name: pod_ip
valueFrom:
Expand Down
3 changes: 0 additions & 3 deletions posts/.github/CODEOWNERS

This file was deleted.

1 change: 0 additions & 1 deletion posts/.github/pull_request_template.md

This file was deleted.

38 changes: 0 additions & 38 deletions posts/.github/workflows/python-package.yml

This file was deleted.

48 changes: 48 additions & 0 deletions posts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,54 @@ class InternalScore(BaseModel) :
total: int


######################### uploader things


class UpdateRequest(BaseModel) :
title: Optional[str]
description: Optional[str]
rating: Optional[Rating]
privacy: Optional[Privacy]


class CreateRequest(BaseModel) :
reply_to: Optional[PostId]
title: Optional[str]
description: Optional[str]
rating: Optional[Rating]
privacy: Optional[Privacy]

@validator('reply_to', pre=True, always=True)
def _parent_validator(cls, value) :
if value :
return PostId(value)


class PrivacyRequest(BaseModel) :
_post_id_validator = PostIdValidator

post_id: PostId
privacy: Privacy


class Coordinates(BaseModel) :
top: int
left: int
width: int
height: int


class IconRequest(BaseModel) :
_post_id_validator = PostIdValidator

post_id: PostId
coordinates: Coordinates


class TagPortable(str) :
pass


RssFeed = f"""<rss version="2.0">
<channel>
<title>Timeline | fuzz.ly</title>
Expand Down
15 changes: 13 additions & 2 deletions posts/posts.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,8 +588,19 @@ async def fetchPosts(self: Self, user: KhUser, sort: PostSort, tags: Optional[li
async def getPost(self: Self, user: KhUser, post_id: PostId) -> Post :
post: InternalPost = await self._get_post(post_id)

if await self.authorized(post, user) :
return await self.post(post, user)
if await self.authorized(user, post) :
return await self.post(user, post)

raise NotFound(f'no data was found for the provided post id: {post_id}.')


@HttpErrorHandler('deleting post')
@timed
async def deletePost(self: Self, user: KhUser, post_id: PostId) -> None :
post: InternalPost = await self._get_post(post_id)

if post.user_id == user.user_id :
return await self._delete_post(post_id)

raise NotFound(f'no data was found for the provided post id: {post_id}.')

Expand Down
File renamed without changes.
17 changes: 15 additions & 2 deletions posts/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ async def _get_post(self: Self, post_id: PostId) -> InternalPost :


@timed
async def post(self: Self, ipost: InternalPost, user: KhUser) -> Post :
async def post(self: Self, user: KhUser, ipost: InternalPost) -> Post :
post_id: PostId = PostId(ipost.post_id)
upl: Task[InternalUser] = ensure_future(users._get_user(ipost.user_id))
tags_task: Task[list[InternalTag]] = ensure_future(tagger._fetch_tags_by_post(post_id))
Expand Down Expand Up @@ -254,6 +254,19 @@ async def post(self: Self, ipost: InternalPost, user: KhUser) -> Post :
)


@timed
async def _delete_post(self: Self, post_id: PostId) -> None :
ensure_future(PostKVS.remove_async(post_id))
await self.query_async("""
delete from kheina.public.posts
where posts.post_id = %s;
""", (
post_id.int(),
),
commit=True,
)


@timed
@AerospikeCache('kheina', 'score', '{post_id}', _kvs=ScoreKVS)
async def _get_score(self: Self, post_id: PostId) -> Optional[InternalScore] :
Expand Down Expand Up @@ -402,7 +415,7 @@ async def getScore(self: Self, user: KhUser, post_id: PostId) -> Optional[Score]


@timed
async def authorized(self: Self, ipost: InternalPost, user: KhUser) -> bool :
async def authorized(self: Self, user: KhUser, ipost: InternalPost) -> bool :
"""
Checks if the given user is able to view this set. Follows the given rules:
Expand Down
168 changes: 134 additions & 34 deletions posts/router.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
from asyncio import ensure_future
from html import escape
from typing import List
from typing import List, Optional, Union
from urllib.parse import quote
from uuid import uuid4

from fastapi import APIRouter
from fastapi import APIRouter, File, Form, UploadFile

from shared.backblaze import B2Interface
from shared.config.constants import environment
from shared.exceptions.http_error import UnprocessableEntity
from shared.models._shared import convert_path_post_id
from shared.models.auth import Scope
from shared.server import Request, Response
from shared.timing import timed
from shared.utilities.units import Byte
from users.users import Users

from .models import BaseFetchRequest, FetchCommentsRequest, FetchPostsRequest, GetUserPostsRequest, Post, PostId, RssDateFormat, RssDescription, RssFeed, RssItem, RssMedia, RssTitle, Score, SearchResults, TimelineRequest, VoteRequest
from .models import BaseFetchRequest, CreateRequest, FetchCommentsRequest, FetchPostsRequest, GetUserPostsRequest, IconRequest, Media, Post, PostId, PrivacyRequest, RssDateFormat, RssDescription, RssFeed, RssItem, RssMedia, RssTitle, Score, SearchResults, TimelineRequest, UpdateRequest, VoteRequest
from .posts import Posts
from .uploader import Uploader


postRouter = APIRouter(
Expand All @@ -24,39 +28,126 @@
prefix='/posts',
)

b2 = B2Interface()
posts = Posts()
users = Users()
b2 = B2Interface()
posts = Posts()
users = Users()
uploader = Uploader()


################################################## INTERNAL ##################################################
# @app.get('/i1/post/{post_id}', response_model=InternalPost)
# async def i1Post(req: Request, post_id: PostId) -> InternalPost :
# await req.user.verify_scope(Scope.internal)
# return await posts._get_post(PostId(post_id))
@postRouter.put('')
@timed.root
async def v1CreatePost(req: Request, body: CreateRequest) -> Post :
"""
only auth required
"""
await req.user.authenticated()

if any(body.dict().values()) :
return await uploader.createPostWithFields(
req.user,
body.reply_to,
body.title,
body.description,
body.privacy,
body.rating,
)

return await uploader.createPost(req.user)

# @app.post('/i1/user/{user_id}', response_model=List[InternalPost])
# async def i1User(req: Request, user_id: int, body: BaseFetchRequest) -> List[InternalPost] :
# await req.user.verify_scope(Scope.internal)
# return await posts._fetch_own_posts(user_id, body.sort, body.count, body.page)

@postRouter.patch('/{post_id}', status_code=204)
@timed.root
async def v1UpdatePost(req: Request, post_id: PostId, body: UpdateRequest) -> None :
await req.user.authenticated()
await uploader.updatePostMetadata(
req.user,
convert_path_post_id(post_id),
body.title,
body.description,
body.privacy,
body.rating,
)


# @app.get('/i1/score/{post_id}', response_model=Optional[InternalScore])
# async def i1Score(req: Request, post_id: PostId, ) -> Optional[InternalScore] :
# await req.user.verify_scope(Scope.internal)
# # TODO: this needs to be replaced with a model and updated above
# return await posts._get_score(PostId(post_id))
@postRouter.delete('/{post_id}', status_code=204)
@timed.root
async def v1DeletePost(req: Request, post_id: PostId) -> None :
await req.user.authenticated()
await posts.deletePost(req.user, convert_path_post_id(post_id))


# @app.get('/i1/vote/{post_id}/{user_id}', response_model=int)
# async def i1Vote(req: Request, post_id: PostId, user_id: int) -> int :
# await req.user.verify_scope(Scope.internal)
# # TODO: this needs to be replaced with a model and updated above
# return await posts._get_vote(user_id, PostId(post_id))
@postRouter.post('/image')
@timed.root
async def v1UploadImage(
req: Request,
file: UploadFile = File(None),
post_id: PostId = Form(None),
web_resize: Optional[int] = Form(None),
) -> Media :
"""
FORMDATA: {
"post_id": Optional[str],
"file": image file,
"web_resize": Optional[int],
}
"""
await req.user.authenticated()

# since it doesn't do this for us, send the proper error back
detail: list[dict[str, Union[str, list[str]]]] = []

if not file :
detail.append({
'loc': [
'body',
'file',
],
'msg': 'field required',
'type': 'value_error.missing',
})

if not file.filename :
detail.append({
'loc': [
'body',
'file',
'filename',
],
'msg': 'field required',
'type': 'value_error.missing',
})

if not post_id :
detail.append({
'loc': [
'body',
'post_id',
],
'msg': 'field required',
'type': 'value_error.missing',
})

if detail :
raise UnprocessableEntity(detail=detail)

assert file.filename
file_on_disk: str = f'images/{uuid4().hex}_{file.filename}'

with open(file_on_disk, 'wb') as f :
while chunk := await file.read(Byte.kilobyte.value * 10) :
f.write(chunk)

await file.close()

return await uploader.uploadImage(
user = req.user,
file_on_disk = file_on_disk,
filename = file.filename,
post_id = PostId(post_id),
web_resize = web_resize,
)


################################################## PUBLIC ##################################################
@postRouter.post('/vote', responses={ 200: { 'model': Score } })
@timed.root
async def v1Vote(req: Request, body: VoteRequest) -> Score :
Expand All @@ -65,6 +156,22 @@ async def v1Vote(req: Request, body: VoteRequest) -> Score :
return await posts.vote(req.user, body.post_id, vote)


# TODO: these should go in users tbh
@postRouter.patch('/icon', status_code=204)
@timed.root
async def v1SetIcon(req: Request, body: IconRequest) -> None :
await req.user.authenticated()
await uploader.setIcon(req.user, body.post_id, body.coordinates)


# TODO: these should go in users tbh
@postRouter.patch('/banner', status_code=204)
@timed.root
async def v1SetBanner(req: Request, body: IconRequest) -> None :
await req.user.authenticated()
await uploader.setBanner(req.user, body.post_id, body.coordinates)


@postsRouter.post('', responses={ 200: { 'model': SearchResults } })
@timed.root
async def v1FetchPosts(req: Request, body: FetchPostsRequest) -> SearchResults :
Expand Down Expand Up @@ -169,14 +276,7 @@ async def v1Rss(req: Request) -> Response :
@postRouter.get('/{post_id}', responses={ 200: { 'model': Post } })
@timed.root
async def v1Post(req: Request, post_id: PostId) -> Post :
try :
# fastapi doesn't parse to PostId automatically, only str
post_id = PostId(post_id)

except ValueError as e :
raise UnprocessableEntity(str(e))

return await posts.getPost(req.user, post_id)
return await posts.getPost(req.user, convert_path_post_id(post_id))


app = APIRouter(
Expand Down
Loading

0 comments on commit 6a1f7b8

Please sign in to comment.