aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/graphql/resolver
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-11-02 00:00:35 +0900
committernsfisis <nsfisis@gmail.com>2025-11-02 00:00:35 +0900
commit104341ddc4add57f83c58cb3fabb23b6fbfdd3e4 (patch)
tree862b109fe257e6170a88929729dae3bddfb6eb49 /backend/graphql/resolver
parentba1e0c904f810193f25d4f88cc2bb168f1d625fe (diff)
downloadfeedaka-feat/multi-user.tar.gz
feedaka-feat/multi-user.tar.zst
feedaka-feat/multi-user.zip
Diffstat (limited to 'backend/graphql/resolver')
-rw-r--r--backend/graphql/resolver/auth_helpers.go35
-rw-r--r--backend/graphql/resolver/resolver.go7
-rw-r--r--backend/graphql/resolver/schema.resolvers.go216
3 files changed, 245 insertions, 13 deletions
diff --git a/backend/graphql/resolver/auth_helpers.go b/backend/graphql/resolver/auth_helpers.go
new file mode 100644
index 0000000..433e9e9
--- /dev/null
+++ b/backend/graphql/resolver/auth_helpers.go
@@ -0,0 +1,35 @@
+package resolver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/labstack/echo/v4"
+ "golang.org/x/crypto/bcrypt"
+ appcontext "undef.ninja/x/feedaka/context"
+)
+
+// getUserIDFromContext retrieves the authenticated user ID from context
+// This is a wrapper around the GetUserID function from the context package
+func getUserIDFromContext(ctx context.Context) (int64, error) {
+ userID, ok := appcontext.GetUserID(ctx)
+ if !ok {
+ return 0, fmt.Errorf("authentication required")
+ }
+ return userID, nil
+}
+
+// Helper function to get Echo context from GraphQL context
+func getEchoContext(ctx context.Context) (echo.Context, error) {
+ echoCtx, ok := ctx.Value("echo").(echo.Context)
+ if !ok {
+ return nil, errors.New("echo context not found")
+ }
+ return echoCtx, nil
+}
+
+func verifyPassword(hashedPassword, password string) bool {
+ err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
+ return err == nil
+}
diff --git a/backend/graphql/resolver/resolver.go b/backend/graphql/resolver/resolver.go
index 7a9c389..dea85a0 100644
--- a/backend/graphql/resolver/resolver.go
+++ b/backend/graphql/resolver/resolver.go
@@ -2,6 +2,8 @@ package resolver
import (
"database/sql"
+
+ "undef.ninja/x/feedaka/auth"
"undef.ninja/x/feedaka/db"
)
@@ -10,6 +12,7 @@ import (
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
- DB *sql.DB
- Queries *db.Queries
+ DB *sql.DB
+ Queries *db.Queries
+ SessionConfig *auth.SessionConfig
}
diff --git a/backend/graphql/resolver/schema.resolvers.go b/backend/graphql/resolver/schema.resolvers.go
index 0c811c2..2caa721 100644
--- a/backend/graphql/resolver/schema.resolvers.go
+++ b/backend/graphql/resolver/schema.resolvers.go
@@ -19,6 +19,11 @@ import (
// AddFeed is the resolver for the addFeed field.
func (r *mutationResolver) AddFeed(ctx context.Context, url string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
// Fetch the feed to get its title
fp := gofeed.NewParser()
feed, err := fp.ParseURL(url)
@@ -31,7 +36,7 @@ func (r *mutationResolver) AddFeed(ctx context.Context, url string) (*model.Feed
Url: url,
Title: feed.Title,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
- UserID: int64(1), // TODO
+ UserID: userID,
})
if err != nil {
return nil, fmt.Errorf("failed to insert feed: %w", err)
@@ -63,12 +68,29 @@ func (r *mutationResolver) AddFeed(ctx context.Context, url string) (*model.Feed
// UnsubscribeFeed is the resolver for the unsubscribeFeed field.
func (r *mutationResolver) UnsubscribeFeed(ctx context.Context, id string) (bool, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return false, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return false, fmt.Errorf("invalid feed ID: %w", err)
}
- err = r.Queries.UnsubscribeFeed(ctx, feedID)
+ // Check if feed exists and belongs to user
+ feed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return false, fmt.Errorf("feed not found or access denied")
+ }
+ return false, fmt.Errorf("failed to query feed: %w", err)
+ }
+
+ err = r.Queries.UnsubscribeFeed(ctx, feed.ID)
if err != nil {
return false, fmt.Errorf("failed to unsubscribe from feed: %w", err)
}
@@ -78,15 +100,32 @@ func (r *mutationResolver) UnsubscribeFeed(ctx context.Context, id string) (bool
// MarkArticleRead is the resolver for the markArticleRead field.
func (r *mutationResolver) MarkArticleRead(ctx context.Context, id string) (*model.Article, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
articleID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid article ID: %w", err)
}
+ // Check if article exists and belongs to user
+ article, err := r.Queries.GetArticle(ctx, db.GetArticleParams{
+ ID: articleID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("article not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query article: %w", err)
+ }
+
// Update the article's read status
err = r.Queries.UpdateArticleReadStatus(ctx, db.UpdateArticleReadStatusParams{
IsRead: 1,
- ID: articleID,
+ ID: article.ID,
})
if err != nil {
return nil, fmt.Errorf("failed to mark article as read: %w", err)
@@ -98,15 +137,32 @@ func (r *mutationResolver) MarkArticleRead(ctx context.Context, id string) (*mod
// MarkArticleUnread is the resolver for the markArticleUnread field.
func (r *mutationResolver) MarkArticleUnread(ctx context.Context, id string) (*model.Article, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
articleID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid article ID: %w", err)
}
+ // Check if article exists and belongs to user
+ article, err := r.Queries.GetArticle(ctx, db.GetArticleParams{
+ ID: articleID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("article not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query article: %w", err)
+ }
+
// Update the article's read status
err = r.Queries.UpdateArticleReadStatus(ctx, db.UpdateArticleReadStatusParams{
IsRead: 0,
- ID: articleID,
+ ID: article.ID,
})
if err != nil {
return nil, fmt.Errorf("failed to mark article as unread: %w", err)
@@ -118,13 +174,30 @@ func (r *mutationResolver) MarkArticleUnread(ctx context.Context, id string) (*m
// MarkFeedRead is the resolver for the markFeedRead field.
func (r *mutationResolver) MarkFeedRead(ctx context.Context, id string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid feed ID: %w", err)
}
+ // Check if feed exists and belongs to user
+ feed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("feed not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query feed: %w", err)
+ }
+
// Update all articles in the feed to be read
- err = r.Queries.MarkFeedArticlesRead(ctx, feedID)
+ err = r.Queries.MarkFeedArticlesRead(ctx, feed.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark feed as read: %w", err)
}
@@ -135,13 +208,30 @@ func (r *mutationResolver) MarkFeedRead(ctx context.Context, id string) (*model.
// MarkFeedUnread is the resolver for the markFeedUnread field.
func (r *mutationResolver) MarkFeedUnread(ctx context.Context, id string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid feed ID: %w", err)
}
+ // Check if feed exists and belongs to user
+ feed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("feed not found or access denied")
+ }
+ return nil, fmt.Errorf("failed to query feed: %w", err)
+ }
+
// Update all articles in the feed to be unread
- err = r.Queries.MarkFeedArticlesUnread(ctx, feedID)
+ err = r.Queries.MarkFeedArticlesUnread(ctx, feed.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark feed as unread: %w", err)
}
@@ -150,9 +240,65 @@ func (r *mutationResolver) MarkFeedUnread(ctx context.Context, id string) (*mode
return r.Query().Feed(ctx, id)
}
+// Login is the resolver for the login field.
+func (r *mutationResolver) Login(ctx context.Context, username string, password string) (*model.AuthPayload, error) {
+ // Verify user credentials
+ user, err := r.Queries.GetUserByUsername(ctx, username)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("invalid credentials")
+ }
+ return nil, fmt.Errorf("failed to query user: %w", err)
+ }
+
+ // Verify password
+ if !verifyPassword(user.PasswordHash, password) {
+ return nil, fmt.Errorf("invalid credentials")
+ }
+
+ // Get Echo context to create session
+ echoCtx, err := getEchoContext(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get echo context: %w", err)
+ }
+
+ // Create session and store user ID
+ if err := r.SessionConfig.SetUserID(echoCtx, user.ID); err != nil {
+ return nil, fmt.Errorf("failed to create session: %w", err)
+ }
+
+ return &model.AuthPayload{
+ User: &model.User{
+ ID: strconv.FormatInt(user.ID, 10),
+ Username: user.Username,
+ },
+ }, nil
+}
+
+// Logout is the resolver for the logout field.
+func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
+ // Get Echo context to destroy session
+ echoCtx, err := getEchoContext(ctx)
+ if err != nil {
+ return false, fmt.Errorf("failed to get echo context: %w", err)
+ }
+
+ // Destroy session
+ if err := r.SessionConfig.DestroySession(echoCtx); err != nil {
+ return false, fmt.Errorf("failed to destroy session: %w", err)
+ }
+
+ return true, nil
+}
+
// Feeds is the resolver for the feeds field.
func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) {
- dbFeeds, err := r.Queries.GetFeeds(ctx)
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ dbFeeds, err := r.Queries.GetFeeds(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to query feeds: %w", err)
}
@@ -173,7 +319,12 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) {
// UnreadArticles is the resolver for the unreadArticles field.
func (r *queryResolver) UnreadArticles(ctx context.Context) ([]*model.Article, error) {
- rows, err := r.Queries.GetUnreadArticles(ctx)
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := r.Queries.GetUnreadArticles(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to query unread articles: %w", err)
}
@@ -201,7 +352,12 @@ func (r *queryResolver) UnreadArticles(ctx context.Context) ([]*model.Article, e
// ReadArticles is the resolver for the readArticles field.
func (r *queryResolver) ReadArticles(ctx context.Context) ([]*model.Article, error) {
- rows, err := r.Queries.GetReadArticles(ctx)
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := r.Queries.GetReadArticles(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to query read articles: %w", err)
}
@@ -229,12 +385,20 @@ func (r *queryResolver) ReadArticles(ctx context.Context) ([]*model.Article, err
// Feed is the resolver for the feed field.
func (r *queryResolver) Feed(ctx context.Context, id string) (*model.Feed, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
feedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid feed ID: %w", err)
}
- dbFeed, err := r.Queries.GetFeed(ctx, feedID)
+ dbFeed, err := r.Queries.GetFeed(ctx, db.GetFeedParams{
+ ID: feedID,
+ UserID: userID,
+ })
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("feed not found")
@@ -253,12 +417,20 @@ func (r *queryResolver) Feed(ctx context.Context, id string) (*model.Feed, error
// Article is the resolver for the article field.
func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
articleID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid article ID: %w", err)
}
- row, err := r.Queries.GetArticle(ctx, articleID)
+ row, err := r.Queries.GetArticle(ctx, db.GetArticleParams{
+ ID: articleID,
+ UserID: userID,
+ })
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("article not found")
@@ -281,6 +453,28 @@ func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article,
}, nil
}
+// Me is the resolver for the me field.
+func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ // Not authenticated - return nil (not an error)
+ return nil, nil
+ }
+
+ user, err := r.Queries.GetUserByID(ctx, userID)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to query user: %w", err)
+ }
+
+ return &model.User{
+ ID: strconv.FormatInt(user.ID, 10),
+ Username: user.Username,
+ }, nil
+}
+
// Mutation returns gql.MutationResolver implementation.
func (r *Resolver) Mutation() gql.MutationResolver { return &mutationResolver{r} }