diff --git a/api/openapi.yaml b/api/openapi.yaml index 7900d9a2..4a2da230 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -61,9 +61,15 @@ paths: application/json: schema: $ref: "#/components/schemas/UpdateUser" + security: + - BearerAuth: [] responses: "200": description: Updated user + content: + application/json: + schema: + $ref: "#/components/schemas/User" /api/users/{id}/posts: get: summary: Get posts of user with ID @@ -218,6 +224,8 @@ paths: required: true schema: type: integer + security: + - BearerAuth: [] responses: "204": description: Deleted successfully @@ -235,6 +243,8 @@ paths: application/json: schema: $ref: "#/components/schemas/UpdatePost" + security: + - BearerAuth: [] responses: "200": description: Updated user diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 70f869e3..ac8a3de8 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -582,6 +582,12 @@ func (siw *ServerInterfaceWrapper) DeleteApiPostsId(w http.ResponseWriter, r *ht return } + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.DeleteApiPostsId(w, r, id) })) @@ -632,6 +638,12 @@ func (siw *ServerInterfaceWrapper) PutApiPostsId(w http.ResponseWriter, r *http. return } + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.PutApiPostsId(w, r, id) })) @@ -889,6 +901,12 @@ func (siw *ServerInterfaceWrapper) PutApiUsersId(w http.ResponseWriter, r *http. return } + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.PutApiUsersId(w, r, id) })) @@ -1276,31 +1294,31 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/db/query.sql b/internal/db/query.sql index 4239f027..a7fab1fd 100644 --- a/internal/db/query.sql +++ b/internal/db/query.sql @@ -119,6 +119,12 @@ INSERT INTO statuses ( $1, $2, $3, $4, $5, $6 ); +-- name: UpdateStatusById :one +UPDATE statuses +SET content = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING *; + -- name: DeleteStatusByID :exec DELETE FROM statuses WHERE id = $1; @@ -189,3 +195,21 @@ JOIN accounts a ON s.account_id = a.id JOIN follows f ON a.id = f.target_account_id WHERE f.account_id = $1 AND s.in_reply_to_id IS NULL ORDER BY s.created_at DESC; + +-- name: GetCommentsByPostId :many +SELECT + s.id, + s.created_at, + s.updated_at, + s.content, + s.account_id, + s.in_reply_to_id +FROM statuses s +WHERE s.in_reply_to_id = $1 +ORDER BY s.created_at ASC; + +-- name: UpdateAccountById :one +UPDATE accounts +SET display_name = COALESCE($2, display_name), updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING *; diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 0ca3787e..3a78192e 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -8,6 +8,7 @@ package db import ( "context" "database/sql" + "time" ) const addStatus = `-- name: AddStatus :exec @@ -462,6 +463,58 @@ func (q *Queries) GetActorByURI(ctx context.Context, dollar_1 string) (Account, return i, err } +const getCommentsByPostId = `-- name: GetCommentsByPostId :many +SELECT + s.id, + s.created_at, + s.updated_at, + s.content, + s.account_id, + s.in_reply_to_id +FROM statuses s +WHERE s.in_reply_to_id = $1 +ORDER BY s.created_at ASC +` + +type GetCommentsByPostIdRow struct { + ID int32 + CreatedAt time.Time + UpdatedAt time.Time + Content string + AccountID int32 + InReplyToID sql.NullInt32 +} + +func (q *Queries) GetCommentsByPostId(ctx context.Context, inReplyToID sql.NullInt32) ([]GetCommentsByPostIdRow, error) { + rows, err := q.db.QueryContext(ctx, getCommentsByPostId, inReplyToID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetCommentsByPostIdRow + for rows.Next() { + var i GetCommentsByPostIdRow + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Content, + &i.AccountID, + &i.InReplyToID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getFavouriteByURI = `-- name: GetFavouriteByURI :one SELECT id, created_at, updated_at, uri, account_id, status_id FROM favourites WHERE uri LIKE '%' || $1::text ` @@ -1054,3 +1107,65 @@ func (q *Queries) GetTimelinePostsByAccountId(ctx context.Context, accountID int } return items, nil } + +const updateAccountById = `-- name: UpdateAccountById :one +UPDATE accounts +SET display_name = COALESCE($2, display_name), updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING id, created_at, updated_at, username, uri, display_name, domain, inbox_uri, outbox_uri, followers_uri, following_uri, url +` + +type UpdateAccountByIdParams struct { + ID int32 + DisplayName sql.NullString +} + +func (q *Queries) UpdateAccountById(ctx context.Context, arg UpdateAccountByIdParams) (Account, error) { + row := q.db.QueryRowContext(ctx, updateAccountById, arg.ID, arg.DisplayName) + var i Account + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Username, + &i.Uri, + &i.DisplayName, + &i.Domain, + &i.InboxUri, + &i.OutboxUri, + &i.FollowersUri, + &i.FollowingUri, + &i.Url, + ) + return i, err +} + +const updateStatusById = `-- name: UpdateStatusById :one +UPDATE statuses +SET content = $2, updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING id, created_at, updated_at, uri, url, local, content, account_id, in_reply_to_id, reblog_of_id +` + +type UpdateStatusByIdParams struct { + ID int32 + Content string +} + +func (q *Queries) UpdateStatusById(ctx context.Context, arg UpdateStatusByIdParams) (Status, error) { + row := q.db.QueryRowContext(ctx, updateStatusById, arg.ID, arg.Content) + var i Status + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Uri, + &i.Url, + &i.Local, + &i.Content, + &i.AccountID, + &i.InReplyToID, + &i.ReblogOfID, + ) + return i, err +} diff --git a/internal/repository/account.go b/internal/repository/account.go index faa9d560..7b69678c 100644 --- a/internal/repository/account.go +++ b/internal/repository/account.go @@ -2,6 +2,7 @@ package repository import ( "context" + "database/sql" "github.com/kibirisu/borg/internal/db" ) @@ -15,6 +16,7 @@ type AccountRepository interface { GetFollowers(context.Context, int) ([]db.Account, error) GetFollowing(context.Context, int) ([]db.Account, error) GetPosts(context.Context, int) ([]db.GetStatusesByAccountIdRow, error) + Update(context.Context, int, *string) (db.Account, error) } type accountRepository struct { @@ -80,3 +82,22 @@ func (r *accountRepository) GetPosts( ) ([]db.GetStatusesByAccountIdRow, error) { return r.q.GetStatusesByAccountId(ctx, int32(id)) } + +// Update implements AccountRepository. +func (r *accountRepository) Update( + ctx context.Context, + id int, + bio *string, +) (db.Account, error) { + var displayName sql.NullString + if bio != nil { + displayName = sql.NullString{ + String: *bio, + Valid: true, + } + } + return r.q.UpdateAccountById(ctx, db.UpdateAccountByIdParams{ + ID: int32(id), + DisplayName: displayName, + }) +} diff --git a/internal/repository/status.go b/internal/repository/status.go index 70fd2c42..b013265c 100644 --- a/internal/repository/status.go +++ b/internal/repository/status.go @@ -12,11 +12,13 @@ type StatusRepository interface { Add(context.Context, db.AddStatusParams) error GetByID(context.Context, int) (db.Status, error) GetByURI(context.Context, string) (db.Status, error) + Update(context.Context, int, string) (db.Status, error) GetShares(context.Context, int) ([]db.Status, error) GetLocalStatuses(context.Context) ([]db.GetLocalStatusesRow, error) GetByIDWithMetadata(context.Context, int) (db.GetStatusByIdWithMetadataRow, error) GetSharedPostsByAccountId(context.Context, int) ([]db.GetSharedPostsByAccountIdRow, error) GetTimelinePostsByAccountId(context.Context, int) ([]db.GetTimelinePostsByAccountIdRow, error) + GetCommentsByPostId(context.Context, int) ([]db.GetCommentsByPostIdRow, error) DeleteByID(context.Context, int32) error } @@ -49,6 +51,18 @@ func (r *statusRepository) GetByURI(ctx context.Context, uri string) (db.Status, return r.q.GetStatusByURI(ctx, uri) } +// Update implements StatusRepository. +func (r *statusRepository) Update( + ctx context.Context, + id int, + content string, +) (db.Status, error) { + return r.q.UpdateStatusById(ctx, db.UpdateStatusByIdParams{ + ID: int32(id), + Content: content, + }) +} + // GetById implements StatusRepository. func (r *statusRepository) GetByIDWithMetadata( ctx context.Context, @@ -83,6 +97,14 @@ func (r *statusRepository) GetTimelinePostsByAccountId( return r.q.GetTimelinePostsByAccountId(ctx, int32(accountID)) } +// GetCommentsByPostId implements StatusRepository. +func (r *statusRepository) GetCommentsByPostId( + ctx context.Context, + postID int, +) ([]db.GetCommentsByPostIdRow, error) { + return r.q.GetCommentsByPostId(ctx, sql.NullInt32{Int32: int32(postID), Valid: true}) +} + // DeleteByURI implements StatusRepository. func (r *statusRepository) DeleteByID(ctx context.Context, id int32) error { return r.q.DeleteStatusByID(ctx, id) diff --git a/internal/server/api.go b/internal/server/api.go index 2696388e..05cec59f 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -214,14 +214,29 @@ func (s *Server) DeleteApiUsersId(w http.ResponseWriter, r *http.Request, id int // GetApiUsersId implements api.ServerInterface. func (s *Server) GetApiUsersId(w http.ResponseWriter, r *http.Request, id int) { - user, err := s.service.App.GetAccountByID(r.Context(), id) + account, err := s.service.App.GetAccountByID(r.Context(), id) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } + + // Get followers and following counts + followers, err := s.service.App.GetAccountFollowers(r.Context(), id) + if err != nil { + log.Println(err) + followers = []db.Account{} + } + + following, err := s.service.App.GetAccountFollowing(r.Context(), id) + if err != nil { + log.Println(err) + following = []db.Account{} + } + + user := mapper.AccountToUserAPI(&account, len(followers), len(following)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - util.WriteJSON(w, http.StatusOK, *mapper.AccountToAPI(&user)) + util.WriteJSON(w, http.StatusOK, *user) } // PostApiUsers implements api.ServerInterface. @@ -279,12 +294,84 @@ func (s *Server) PostApiUsers(w http.ResponseWriter, r *http.Request) { // PutApiUsersId implements api.ServerInterface. func (s *Server) PutApiUsersId(w http.ResponseWriter, r *http.Request, id int) { - panic("unimplemented") + container, ok := r.Context().Value(TokenContextKey).(*tokenContainer) + if !ok || container == nil || container.id == nil { + util.WriteError(w, http.StatusUnauthorized, "User not authenticated") + return + } + currentUserID := *container.id + + if id != currentUserID { + util.WriteError(w, http.StatusForbidden, "Forbidden: You can only update your own account") + return + } + + _, err := s.service.App.GetAccountByID(r.Context(), id) + if err != nil { + util.WriteError(w, http.StatusNotFound, err.Error()) + return + } + + var update api.UpdateUser + if err := util.ReadJSON(r, &update); err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + + var bio *string + if update.Bio != nil { + bio = update.Bio + } + + updatedAccount, err := s.service.App.UpdateAccount(r.Context(), id, bio) + if err != nil { + util.WriteError(w, http.StatusInternalServerError, "Internal server error") + return + } + + followers, err := s.service.App.GetAccountFollowers(r.Context(), id) + if err != nil { + log.Println(err) + followers = []db.Account{} + } + + following, err := s.service.App.GetAccountFollowing(r.Context(), id) + if err != nil { + log.Println(err) + following = []db.Account{} + } + + user := mapper.AccountToUserAPI(&updatedAccount, len(followers), len(following)) + util.WriteJSON(w, http.StatusOK, *user) } // DeleteApiPostsId implements api.ServerInterface. func (s *Server) DeleteApiPostsId(w http.ResponseWriter, r *http.Request, id int) { - panic("unimplemented") + container, ok := r.Context().Value(TokenContextKey).(*tokenContainer) + if !ok || container == nil || container.id == nil { + util.WriteError(w, http.StatusUnauthorized, "User not authenticated") + return + } + currentUserID := *container.id + + post, err := s.service.App.GetPostByID(r.Context(), id) + if err != nil { + util.WriteError(w, http.StatusNotFound, err.Error()) + return + } + + if int(post.AccountID) != currentUserID { + util.WriteError(w, http.StatusForbidden, "Forbidden: You can only delete your own posts") + return + } + + err = s.service.App.DeletePost(r.Context(), id) + if err != nil { + util.WriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) } // GetApiPostsId implements api.ServerInterface. @@ -294,8 +381,6 @@ func (s *Server) GetApiPostsId(w http.ResponseWriter, r *http.Request, id int) { http.Error(w, "Database error", http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) util.WriteJSON(w, http.StatusOK, *mapper.PostToAPIWithMetadata(&info.Status, &info.Account, int(info.LikeCount), @@ -305,7 +390,21 @@ func (s *Server) GetApiPostsId(w http.ResponseWriter, r *http.Request, id int) { // GetApiPostsIdComments implements api.ServerInterface. func (s *Server) GetApiPostsIdComments(w http.ResponseWriter, r *http.Request, id int) { - panic("unimplemented") + comments, err := s.service.App.GetCommentsByPostId(r.Context(), id) + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + apiComments := make([]api.Comment, 0, len(comments)) + for _, comment := range comments { + converted := mapper.StatusToComment(&comment) + apiComments = append(apiComments, *converted) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + util.WriteJSON(w, http.StatusOK, apiComments) } // PostApiPostsIdComments implements api.ServerInterface. @@ -492,7 +591,63 @@ func (s *Server) PostApiPosts(w http.ResponseWriter, r *http.Request) { // PutApiPostsId implements api.ServerInterface. func (s *Server) PutApiPostsId(w http.ResponseWriter, r *http.Request, id int) { - panic("unimplemented") + // 1. Authorization - check if user is authenticated + container, ok := r.Context().Value(TokenContextKey).(*tokenContainer) + if !ok || container == nil || container.id == nil { + util.WriteError(w, http.StatusUnauthorized, "User not authenticated") + return + } + currentUserID := *container.id + + // 2. Check if post exists and get current post data + post, err := s.service.App.GetPostByID(r.Context(), id) + if err != nil { + http.Error(w, "Post not found", http.StatusNotFound) + return + } + + // 3. Check ownership - only owner can update their post + if int(post.AccountID) != currentUserID { + util.WriteError(w, http.StatusForbidden, "Forbidden: You can only update your own posts") + return + } + + // 4. Read request body + var update api.UpdatePost + if err := util.ReadJSON(r, &update); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + // 5. Validate content - check if content is provided + if update.Content == nil || *update.Content == "" { + http.Error(w, "Content cannot be empty", http.StatusBadRequest) + return + } + + // 6. Update the post + _, err = s.service.App.UpdatePost(r.Context(), id, *update.Content) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // 7. Get updated post with metadata for response + info, err := s.service.App.GetPostByIDWithMetadata(r.Context(), id) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // 8. Return updated post with metadata + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + util.WriteJSON(w, http.StatusOK, *mapper.PostToAPIWithMetadata( + &info.Status, + &info.Account, + int(info.LikeCount), + int(info.ShareCount), + int(info.CommentCount))) } // GetApiUsersIdPosts implements api.ServerInterface. diff --git a/internal/server/mapper/api.go b/internal/server/mapper/api.go index ecf447ba..dde79e15 100644 --- a/internal/server/mapper/api.go +++ b/internal/server/mapper/api.go @@ -75,3 +75,19 @@ func AccountToUserAPI(account *db.Account, followersCount int, followingCount in UpdatedAt: account.UpdatedAt, } } + +func StatusToComment(status *db.GetCommentsByPostIdRow) *api.Comment { + postID := 0 + if status.InReplyToID.Valid { + postID = int(status.InReplyToID.Int32) + } + return &api.Comment{ + Id: int(status.ID), + PostID: postID, + UserID: int(status.AccountID), + Content: status.Content, + ParentID: postID, // Comments have parentID same as postID + CreatedAt: status.CreatedAt, + UpdatedAt: status.UpdatedAt, + } +} diff --git a/internal/service/app.go b/internal/service/app.go index bd2685b1..a9cb381a 100644 --- a/internal/service/app.go +++ b/internal/service/app.go @@ -34,16 +34,20 @@ type AppService interface { AddFavourite(context.Context, int, int) (db.Favourite, error) FollowAccount(context.Context, int, int) (*db.Follow, error) GetAccountByID(context.Context, int) (db.Account, error) + UpdateAccount(context.Context, int, *string) (db.Account, error) GetAccount(context.Context, db.GetAccountParams) (*db.Account, error) GetLocalPosts(context.Context) ([]db.GetLocalStatusesRow, error) GetPostByAccountID(context.Context, int) ([]db.GetStatusesByAccountIdRow, error) GetPostByID(context.Context, int) (*db.Status, error) + UpdatePost(context.Context, int, string) (*db.Status, error) + DeletePost(context.Context, int) error GetPostLikes(context.Context, int) ([]db.Favourite, error) GetPostShares(context.Context, int) ([]db.Status, error) GetPostByIDWithMetadata(context.Context, int) (*db.GetStatusByIdWithMetadataRow, error) GetLikedPostsByAccountId(context.Context, int) ([]db.GetLikedPostsByAccountIdRow, error) GetSharedPostsByAccountId(context.Context, int) ([]db.GetSharedPostsByAccountIdRow, error) GetTimelinePostsByAccountId(context.Context, int) ([]db.GetTimelinePostsByAccountIdRow, error) + GetCommentsByPostId(context.Context, int) ([]db.GetCommentsByPostIdRow, error) // EW, idk if this should stay here DeliverToFollowers(http.ResponseWriter, *http.Request, int, func(recipientURI string) any) } @@ -253,6 +257,13 @@ func (s *appService) GetAccountByID( return s.store.Accounts().GetByID(ctx, accountID) } +// UpdateAccount implements AppService. +func (s *appService) UpdateAccount( + ctx context.Context, accountID int, bio *string, +) (db.Account, error) { + return s.store.Accounts().Update(ctx, accountID, bio) +} + // AddFavourite implements AppService. func (s *appService) AddFavourite( ctx context.Context, accountID int, postID int, @@ -305,6 +316,18 @@ func (s *appService) GetPostByID(ctx context.Context, id int) (*db.Status, error } } +func (s *appService) UpdatePost(ctx context.Context, id int, content string) (*db.Status, error) { + status, err := s.store.Statuses().Update(ctx, id, content) + if err != nil { + return nil, err + } + return &status, nil +} + +func (s *appService) DeletePost(ctx context.Context, id int) error { + return s.store.Statuses().DeleteByID(ctx, int32(id)) +} + func (s *appService) GetPostLikes(ctx context.Context, id int) ([]db.Favourite, error) { return s.store.Favourites().GetByPost(ctx, id) } @@ -381,3 +404,10 @@ func (s *appService) GetTimelinePostsByAccountId( ) ([]db.GetTimelinePostsByAccountIdRow, error) { return s.store.Statuses().GetTimelinePostsByAccountId(ctx, accountID) } + +func (s *appService) GetCommentsByPostId( + ctx context.Context, + postID int, +) ([]db.GetCommentsByPostIdRow, error) { + return s.store.Statuses().GetCommentsByPostId(ctx, postID) +} diff --git a/web/src/components/pages/OtherUserPage.tsx b/web/src/components/pages/OtherUserPage.tsx index 1b4088de..7b40c51f 100644 --- a/web/src/components/pages/OtherUserPage.tsx +++ b/web/src/components/pages/OtherUserPage.tsx @@ -65,7 +65,7 @@ export default function OtherUserPage() { } return { username: res.data.username, - bio: (res.data as any).bio ?? derivedBio, + bio: res.data.bio ?? derivedBio, }; }, }); diff --git a/web/src/components/pages/UserPage.tsx b/web/src/components/pages/UserPage.tsx index 29a0f9ae..e5c00427 100644 --- a/web/src/components/pages/UserPage.tsx +++ b/web/src/components/pages/UserPage.tsx @@ -63,7 +63,7 @@ export default function UserPage() { } return { username: res.data.username, - bio: (res.data as any).bio ?? derivedBio, + bio: res.data.bio ?? derivedBio, }; }, }); diff --git a/web/src/lib/api/v1.d.ts b/web/src/lib/api/v1.d.ts index 518b63ea..3e9c1239 100644 --- a/web/src/lib/api/v1.d.ts +++ b/web/src/lib/api/v1.d.ts @@ -93,7 +93,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["User"]; + }; }; }; };