diff options
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/auth/session.go | 99 | ||||
| -rw-r--r-- | backend/cmd_serve.go | 33 | ||||
| -rw-r--r-- | backend/context/user.go | 20 | ||||
| -rw-r--r-- | backend/db/articles.sql.go | 23 | ||||
| -rw-r--r-- | backend/db/feeds.sql.go | 28 | ||||
| -rw-r--r-- | backend/db/queries/articles.sql | 6 | ||||
| -rw-r--r-- | backend/db/queries/feeds.sql | 6 | ||||
| -rw-r--r-- | backend/go.mod | 12 | ||||
| -rw-r--r-- | backend/go.sum | 18 | ||||
| -rw-r--r-- | backend/graphql/generated.go | 588 | ||||
| -rw-r--r-- | backend/graphql/model/generated.go | 14 | ||||
| -rw-r--r-- | backend/graphql/resolver/auth_helpers.go | 35 | ||||
| -rw-r--r-- | backend/graphql/resolver/resolver.go | 7 | ||||
| -rw-r--r-- | backend/graphql/resolver/schema.resolvers.go | 216 | ||||
| -rw-r--r-- | backend/middleware.go | 25 |
15 files changed, 1085 insertions, 45 deletions
diff --git a/backend/auth/session.go b/backend/auth/session.go new file mode 100644 index 0000000..b78de4e --- /dev/null +++ b/backend/auth/session.go @@ -0,0 +1,99 @@ +package auth + +import ( + "errors" + "os" + + "github.com/gorilla/sessions" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" +) + +const ( + sessionName = "feedaka_session" + sessionUserIDKey = "user_id" + // Session duration: 7 days + sessionMaxAge = 7 * 24 * 60 * 60 +) + +var ( + ErrNoSession = errors.New("no session found") + ErrNoUserIDInSession = errors.New("no user_id in session") +) + +type SessionConfig struct { + store *sessions.CookieStore +} + +// NewSessionConfig creates a new session configuration. +// Reads SESSION_SECRET from environment variable. +// If not set, uses a default value (NOT recommended for production). +func NewSessionConfig() *SessionConfig { + secret := os.Getenv("SESSION_SECRET") + if secret == "" { + // Default secret for development - CHANGE THIS IN PRODUCTION + secret = "feedaka-default-session-secret-change-me-in-production" + } + + store := sessions.NewCookieStore([]byte(secret)) + store.Options = &sessions.Options{ + Path: "/", + MaxAge: sessionMaxAge, + HttpOnly: true, + Secure: true, // Set to true in production with HTTPS + SameSite: 3, // SameSite=Strict + } + + return &SessionConfig{ + store: store, + } +} + +// GetStore returns the session store. +func (c *SessionConfig) GetStore() *sessions.CookieStore { + return c.store +} + +// SetUserID stores the user ID in the session. +func (c *SessionConfig) SetUserID(ctx echo.Context, userID int64) error { + sess, err := session.Get(sessionName, ctx) + if err != nil { + return err + } + + sess.Values[sessionUserIDKey] = userID + return sess.Save(ctx.Request(), ctx.Response()) +} + +// GetUserID retrieves the user ID from the session. +func (c *SessionConfig) GetUserID(ctx echo.Context) (int64, error) { + sess, err := session.Get(sessionName, ctx) + if err != nil { + return 0, ErrNoSession + } + + userIDVal, ok := sess.Values[sessionUserIDKey] + if !ok { + return 0, ErrNoUserIDInSession + } + + userID, ok := userIDVal.(int64) + if !ok { + return 0, ErrNoUserIDInSession + } + + return userID, nil +} + +// DestroySession removes the session. +func (c *SessionConfig) DestroySession(ctx echo.Context) error { + sess, err := session.Get(sessionName, ctx) + if err != nil { + // If there's no session, nothing to destroy + return nil + } + + // Set MaxAge to -1 to delete the session + sess.Options.MaxAge = -1 + return sess.Save(ctx.Request(), ctx.Response()) +} diff --git a/backend/cmd_serve.go b/backend/cmd_serve.go index 66ceaf9..33de515 100644 --- a/backend/cmd_serve.go +++ b/backend/cmd_serve.go @@ -17,11 +17,13 @@ import ( "github.com/99designs/gqlgen/graphql/handler/lru" "github.com/99designs/gqlgen/graphql/handler/transport" "github.com/hashicorp/go-multierror" + "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/mmcdole/gofeed" "github.com/vektah/gqlparser/v2/ast" + "undef.ninja/x/feedaka/auth" "undef.ninja/x/feedaka/db" "undef.ninja/x/feedaka/graphql" "undef.ninja/x/feedaka/graphql/resolver" @@ -149,11 +151,16 @@ func runServe(database *sql.DB) { queries := db.New(database) + // Initialize session config + sessionConfig := auth.NewSessionConfig() + e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Use(middleware.CORS()) + // Setup session middleware + e.Use(session.Middleware(sessionConfig.GetStore())) e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ HTML5: true, @@ -162,7 +169,11 @@ func runServe(database *sql.DB) { })) // Setup GraphQL server - srv := handler.New(graphql.NewExecutableSchema(graphql.Config{Resolvers: &resolver.Resolver{DB: database, Queries: queries}})) + srv := handler.New(graphql.NewExecutableSchema(graphql.Config{Resolvers: &resolver.Resolver{ + DB: database, + Queries: queries, + SessionConfig: sessionConfig, + }})) srv.AddTransport(transport.Options{}) srv.AddTransport(transport.GET{}) @@ -175,9 +186,23 @@ func runServe(database *sql.DB) { Cache: lru.New[string](100), }) - // GraphQL endpoints - e.POST("/graphql", echo.WrapHandler(srv)) - e.GET("/graphql", echo.WrapHandler(srv)) + // GraphQL endpoints with authentication middleware + graphqlGroup := e.Group("/graphql") + graphqlGroup.Use(SessionAuthMiddleware(sessionConfig)) + graphqlGroup.POST("", func(c echo.Context) error { + // Add Echo context to GraphQL context + ctx := context.WithValue(c.Request().Context(), "echo", c) + req := c.Request().WithContext(ctx) + srv.ServeHTTP(c.Response(), req) + return nil + }) + graphqlGroup.GET("", func(c echo.Context) error { + // Add Echo context to GraphQL context + ctx := context.WithValue(c.Request().Context(), "echo", c) + req := c.Request().WithContext(ctx) + srv.ServeHTTP(c.Response(), req) + return nil + }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/backend/context/user.go b/backend/context/user.go new file mode 100644 index 0000000..b8987e4 --- /dev/null +++ b/backend/context/user.go @@ -0,0 +1,20 @@ +package context + +import ( + "context" +) + +type contextKey string + +const userIDContextKey contextKey = "user_id" + +// SetUserID adds the user ID to the context +func SetUserID(ctx context.Context, userID int64) context.Context { + return context.WithValue(ctx, userIDContextKey, userID) +} + +// GetUserID retrieves the user ID from the request context +func GetUserID(ctx context.Context) (int64, bool) { + userID, ok := ctx.Value(userIDContextKey).(int64) + return userID, ok +} diff --git a/backend/db/articles.sql.go b/backend/db/articles.sql.go index 7492598..e4419a8 100644 --- a/backend/db/articles.sql.go +++ b/backend/db/articles.sql.go @@ -78,9 +78,14 @@ SELECT f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.id = ? +WHERE a.id = ? AND f.user_id = ? ` +type GetArticleParams struct { + ID int64 + UserID int64 +} + type GetArticleRow struct { ID int64 FeedID int64 @@ -94,8 +99,8 @@ type GetArticleRow struct { FeedIsSubscribed int64 } -func (q *Queries) GetArticle(ctx context.Context, id int64) (GetArticleRow, error) { - row := q.db.QueryRowContext(ctx, getArticle, id) +func (q *Queries) GetArticle(ctx context.Context, arg GetArticleParams) (GetArticleRow, error) { + row := q.db.QueryRowContext(ctx, getArticle, arg.ID, arg.UserID) var i GetArticleRow err := row.Scan( &i.ID, @@ -184,7 +189,7 @@ SELECT f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 1 AND f.is_subscribed = 1 +WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ? ORDER BY a.id DESC LIMIT 100 ` @@ -202,8 +207,8 @@ type GetReadArticlesRow struct { FeedIsSubscribed int64 } -func (q *Queries) GetReadArticles(ctx context.Context) ([]GetReadArticlesRow, error) { - rows, err := q.db.QueryContext(ctx, getReadArticles) +func (q *Queries) GetReadArticles(ctx context.Context, userID int64) ([]GetReadArticlesRow, error) { + rows, err := q.db.QueryContext(ctx, getReadArticles, userID) if err != nil { return nil, err } @@ -242,7 +247,7 @@ SELECT f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 0 AND f.is_subscribed = 1 +WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ? ORDER BY a.id DESC LIMIT 100 ` @@ -260,8 +265,8 @@ type GetUnreadArticlesRow struct { FeedIsSubscribed int64 } -func (q *Queries) GetUnreadArticles(ctx context.Context) ([]GetUnreadArticlesRow, error) { - rows, err := q.db.QueryContext(ctx, getUnreadArticles) +func (q *Queries) GetUnreadArticles(ctx context.Context, userID int64) ([]GetUnreadArticlesRow, error) { + rows, err := q.db.QueryContext(ctx, getUnreadArticles, userID) if err != nil { return nil, err } diff --git a/backend/db/feeds.sql.go b/backend/db/feeds.sql.go index 140fa3a..5a9b6b9 100644 --- a/backend/db/feeds.sql.go +++ b/backend/db/feeds.sql.go @@ -54,11 +54,16 @@ func (q *Queries) DeleteFeed(ctx context.Context, id int64) error { const getFeed = `-- name: GetFeed :one SELECT id, url, title, fetched_at, is_subscribed, user_id FROM feeds -WHERE id = ? +WHERE id = ? AND user_id = ? ` -func (q *Queries) GetFeed(ctx context.Context, id int64) (Feed, error) { - row := q.db.QueryRowContext(ctx, getFeed, id) +type GetFeedParams struct { + ID int64 + UserID int64 +} + +func (q *Queries) GetFeed(ctx context.Context, arg GetFeedParams) (Feed, error) { + row := q.db.QueryRowContext(ctx, getFeed, arg.ID, arg.UserID) var i Feed err := row.Scan( &i.ID, @@ -74,11 +79,16 @@ func (q *Queries) GetFeed(ctx context.Context, id int64) (Feed, error) { const getFeedByURL = `-- name: GetFeedByURL :one SELECT id, url, title, fetched_at, is_subscribed, user_id FROM feeds -WHERE url = ? +WHERE url = ? AND user_id = ? ` -func (q *Queries) GetFeedByURL(ctx context.Context, url string) (Feed, error) { - row := q.db.QueryRowContext(ctx, getFeedByURL, url) +type GetFeedByURLParams struct { + Url string + UserID int64 +} + +func (q *Queries) GetFeedByURL(ctx context.Context, arg GetFeedByURLParams) (Feed, error) { + row := q.db.QueryRowContext(ctx, getFeedByURL, arg.Url, arg.UserID) var i Feed err := row.Scan( &i.ID, @@ -94,12 +104,12 @@ func (q *Queries) GetFeedByURL(ctx context.Context, url string) (Feed, error) { const getFeeds = `-- name: GetFeeds :many SELECT id, url, title, fetched_at, is_subscribed, user_id FROM feeds -WHERE is_subscribed = 1 +WHERE is_subscribed = 1 AND user_id = ? ORDER BY id ` -func (q *Queries) GetFeeds(ctx context.Context) ([]Feed, error) { - rows, err := q.db.QueryContext(ctx, getFeeds) +func (q *Queries) GetFeeds(ctx context.Context, userID int64) ([]Feed, error) { + rows, err := q.db.QueryContext(ctx, getFeeds, userID) if err != nil { return nil, err } diff --git a/backend/db/queries/articles.sql b/backend/db/queries/articles.sql index c1feaae..b910fcf 100644 --- a/backend/db/queries/articles.sql +++ b/backend/db/queries/articles.sql @@ -4,7 +4,7 @@ SELECT f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.id = ?; +WHERE a.id = ? AND f.user_id = ?; -- name: GetUnreadArticles :many SELECT @@ -12,7 +12,7 @@ SELECT f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 0 AND f.is_subscribed = 1 +WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ? ORDER BY a.id DESC LIMIT 100; @@ -22,7 +22,7 @@ SELECT f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed FROM articles AS a INNER JOIN feeds AS f ON a.feed_id = f.id -WHERE a.is_read = 1 AND f.is_subscribed = 1 +WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ? ORDER BY a.id DESC LIMIT 100; diff --git a/backend/db/queries/feeds.sql b/backend/db/queries/feeds.sql index 9725252..9b0b8f7 100644 --- a/backend/db/queries/feeds.sql +++ b/backend/db/queries/feeds.sql @@ -1,12 +1,12 @@ -- name: GetFeed :one SELECT id, url, title, fetched_at, is_subscribed, user_id FROM feeds -WHERE id = ?; +WHERE id = ? AND user_id = ?; -- name: GetFeeds :many SELECT id, url, title, fetched_at, is_subscribed, user_id FROM feeds -WHERE is_subscribed = 1 +WHERE is_subscribed = 1 AND user_id = ? ORDER BY id; -- name: CreateFeed :one @@ -26,7 +26,7 @@ WHERE id = ?; -- name: GetFeedByURL :one SELECT id, url, title, fetched_at, is_subscribed, user_id FROM feeds -WHERE url = ?; +WHERE url = ? AND user_id = ?; -- name: GetFeedsToFetch :many SELECT id, url, fetched_at, user_id diff --git a/backend/go.mod b/backend/go.mod index 68a6343..cdff2bd 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.4 require ( github.com/99designs/gqlgen v0.17.76 + github.com/gorilla/sessions v1.4.0 github.com/hashicorp/go-multierror v1.1.1 github.com/labstack/echo/v4 v4.13.4 github.com/mattn/go-sqlite3 v1.14.28 @@ -15,7 +16,7 @@ require ( ) require ( - cel.dev/expr v0.19.1 // indirect + cel.dev/expr v0.20.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect @@ -30,6 +31,8 @@ require ( github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/google/cel-go v0.24.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -40,6 +43,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/labstack/echo-contrib v0.17.4 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -78,9 +82,9 @@ require ( golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.34.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect - google.golang.org/grpc v1.71.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.72.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 2d128a0..295d7c9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI= +cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/99designs/gqlgen v0.17.76 h1:YsJBcfACWmXWU2t1yCjoGdOmqcTfOFpjbLAE443fmYI= @@ -50,10 +52,18 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -84,6 +94,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= +github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -280,10 +292,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= diff --git a/backend/graphql/generated.go b/backend/graphql/generated.go index e4790bd..479e590 100644 --- a/backend/graphql/generated.go +++ b/backend/graphql/generated.go @@ -56,6 +56,10 @@ type ComplexityRoot struct { URL func(childComplexity int) int } + AuthPayload struct { + User func(childComplexity int) int + } + Feed struct { Articles func(childComplexity int) int FetchedAt func(childComplexity int) int @@ -67,6 +71,8 @@ type ComplexityRoot struct { Mutation struct { AddFeed func(childComplexity int, url string) int + Login func(childComplexity int, username string, password string) int + Logout func(childComplexity int) int MarkArticleRead func(childComplexity int, id string) int MarkArticleUnread func(childComplexity int, id string) int MarkFeedRead func(childComplexity int, id string) int @@ -78,9 +84,15 @@ type ComplexityRoot struct { Article func(childComplexity int, id string) int Feed func(childComplexity int, id string) int Feeds func(childComplexity int) int + Me func(childComplexity int) int ReadArticles func(childComplexity int) int UnreadArticles func(childComplexity int) int } + + User struct { + ID func(childComplexity int) int + Username func(childComplexity int) int + } } type MutationResolver interface { @@ -90,6 +102,8 @@ type MutationResolver interface { MarkArticleUnread(ctx context.Context, id string) (*model.Article, error) MarkFeedRead(ctx context.Context, id string) (*model.Feed, error) MarkFeedUnread(ctx context.Context, id string) (*model.Feed, error) + Login(ctx context.Context, username string, password string) (*model.AuthPayload, error) + Logout(ctx context.Context) (bool, error) } type QueryResolver interface { Feeds(ctx context.Context) ([]*model.Feed, error) @@ -97,6 +111,7 @@ type QueryResolver interface { ReadArticles(ctx context.Context) ([]*model.Article, error) Feed(ctx context.Context, id string) (*model.Feed, error) Article(ctx context.Context, id string) (*model.Article, error) + Me(ctx context.Context) (*model.User, error) } type executableSchema struct { @@ -167,6 +182,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Article.URL(childComplexity), true + case "AuthPayload.user": + if e.complexity.AuthPayload.User == nil { + break + } + + return e.complexity.AuthPayload.User(childComplexity), true + case "Feed.articles": if e.complexity.Feed.Articles == nil { break @@ -221,6 +243,25 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.AddFeed(childComplexity, args["url"].(string)), true + case "Mutation.login": + if e.complexity.Mutation.Login == nil { + break + } + + args, err := ec.field_Mutation_login_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.Login(childComplexity, args["username"].(string), args["password"].(string)), true + + case "Mutation.logout": + if e.complexity.Mutation.Logout == nil { + break + } + + return e.complexity.Mutation.Logout(childComplexity), true + case "Mutation.markArticleRead": if e.complexity.Mutation.MarkArticleRead == nil { break @@ -312,6 +353,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.Feeds(childComplexity), true + case "Query.me": + if e.complexity.Query.Me == nil { + break + } + + return e.complexity.Query.Me(childComplexity), true + case "Query.readArticles": if e.complexity.Query.ReadArticles == nil { break @@ -326,6 +374,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.UnreadArticles(childComplexity), true + case "User.id": + if e.complexity.User.ID == nil { + break + } + + return e.complexity.User.ID(childComplexity), true + + case "User.username": + if e.complexity.User.Username == nil { + break + } + + return e.complexity.User.Username(childComplexity), true + } return 0, false } @@ -508,6 +570,31 @@ type Article { } """ +Represents a user in the system +""" +type User { + """ + Unique identifier for the user + """ + id: ID! + + """ + Username of the user + """ + username: String! +} + +""" +Authentication payload returned from login mutation +""" +type AuthPayload { + """ + The authenticated user + """ + user: User! +} + +""" Root query type for reading data """ type Query { @@ -535,6 +622,11 @@ type Query { Get a specific article by ID """ article(id: ID!): Article + + """ + Get the currently authenticated user + """ + me: User } """ @@ -570,6 +662,16 @@ type Mutation { Mark all articles in a feed as unread """ markFeedUnread(id: ID!): Feed! + + """ + Login with username and password. Creates a session cookie. + """ + login(username: String!, password: String!): AuthPayload! + + """ + Logout the current user and destroy the session + """ + logout: Boolean! } `, BuiltIn: false}, } @@ -607,6 +709,57 @@ func (ec *executionContext) field_Mutation_addFeed_argsURL( return zeroVal, nil } +func (ec *executionContext) field_Mutation_login_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_login_argsUsername(ctx, rawArgs) + if err != nil { + return nil, err + } + args["username"] = arg0 + arg1, err := ec.field_Mutation_login_argsPassword(ctx, rawArgs) + if err != nil { + return nil, err + } + args["password"] = arg1 + return args, nil +} +func (ec *executionContext) field_Mutation_login_argsUsername( + ctx context.Context, + rawArgs map[string]any, +) (string, error) { + if _, ok := rawArgs["username"]; !ok { + var zeroVal string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("username")) + if tmp, ok := rawArgs["username"]; ok { + return ec.unmarshalNString2string(ctx, tmp) + } + + var zeroVal string + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_login_argsPassword( + ctx context.Context, + rawArgs map[string]any, +) (string, error) { + if _, ok := rawArgs["password"]; !ok { + var zeroVal string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) + if tmp, ok := rawArgs["password"]; ok { + return ec.unmarshalNString2string(ctx, tmp) + } + + var zeroVal string + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_markArticleRead_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1273,6 +1426,56 @@ func (ec *executionContext) fieldContext_Article_feed(_ context.Context, field g return fc, nil } +func (ec *executionContext) _AuthPayload_user(ctx context.Context, field graphql.CollectedField, obj *model.AuthPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthPayload_user(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.User, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.User) + fc.Result = res + return ec.marshalNUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AuthPayload_user(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_User_id(ctx, field) + case "username": + return ec.fieldContext_User_username(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type User", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Feed_id(ctx context.Context, field graphql.CollectedField, obj *model.Feed) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Feed_id(ctx, field) if err != nil { @@ -1957,6 +2160,109 @@ func (ec *executionContext) fieldContext_Mutation_markFeedUnread(ctx context.Con return fc, nil } +func (ec *executionContext) _Mutation_login(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_login(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().Login(rctx, fc.Args["username"].(string), fc.Args["password"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.AuthPayload) + fc.Result = res + return ec.marshalNAuthPayload2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_login(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "user": + return ec.fieldContext_AuthPayload_user(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_login_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_logout(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_logout(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().Logout(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_logout(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Query_feeds(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_feeds(ctx, field) if err != nil { @@ -2269,6 +2575,53 @@ func (ec *executionContext) fieldContext_Query_article(ctx context.Context, fiel return fc, nil } +func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_me(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().Me(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*model.User) + fc.Result = res + return ec.marshalOUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_me(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_User_id(ctx, field) + case "username": + return ec.fieldContext_User_username(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type User", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query___type(ctx, field) if err != nil { @@ -2400,6 +2753,94 @@ func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field return fc, nil } +func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_User_id(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNID2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_User_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "User", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _User_username(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_User_username(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Username, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_User_username(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "User", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { fc, err := ec.fieldContext___Directive_name(ctx, field) if err != nil { @@ -4428,6 +4869,45 @@ func (ec *executionContext) _Article(ctx context.Context, sel ast.SelectionSet, return out } +var authPayloadImplementors = []string{"AuthPayload"} + +func (ec *executionContext) _AuthPayload(ctx context.Context, sel ast.SelectionSet, obj *model.AuthPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, authPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AuthPayload") + case "user": + out.Values[i] = ec._AuthPayload_user(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var feedImplementors = []string{"Feed"} func (ec *executionContext) _Feed(ctx context.Context, sel ast.SelectionSet, obj *model.Feed) graphql.Marshaler { @@ -4553,6 +5033,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "login": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_login(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "logout": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_logout(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -4699,6 +5193,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "me": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_me(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Query___type(ctx, field) @@ -4730,6 +5243,50 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr return out } +var userImplementors = []string{"User"} + +func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *model.User) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, userImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("User") + case "id": + out.Values[i] = ec._User_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "username": + out.Values[i] = ec._User_username(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var __DirectiveImplementors = []string{"__Directive"} func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler { @@ -5123,6 +5680,20 @@ func (ec *executionContext) marshalNArticle2ᚖundefᚗninjaᚋxᚋfeedakaᚋgra return ec._Article(ctx, sel, v) } +func (ec *executionContext) marshalNAuthPayload2undefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx context.Context, sel ast.SelectionSet, v model.AuthPayload) graphql.Marshaler { + return ec._AuthPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNAuthPayload2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx context.Context, sel ast.SelectionSet, v *model.AuthPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AuthPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) @@ -5245,6 +5816,16 @@ func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.S return res } +func (ec *executionContext) marshalNUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx context.Context, sel ast.SelectionSet, v *model.User) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._User(ctx, sel, v) +} + func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler { return ec.___Directive(ctx, sel, &v) } @@ -5560,6 +6141,13 @@ func (ec *executionContext) marshalOString2ᚖstring(ctx context.Context, sel as return res } +func (ec *executionContext) marshalOUser2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐUser(ctx context.Context, sel ast.SelectionSet, v *model.User) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._User(ctx, sel, v) +} + func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/backend/graphql/model/generated.go b/backend/graphql/model/generated.go index 25ed8d8..11f9692 100644 --- a/backend/graphql/model/generated.go +++ b/backend/graphql/model/generated.go @@ -20,6 +20,12 @@ type Article struct { Feed *Feed `json:"feed"` } +// Authentication payload returned from login mutation +type AuthPayload struct { + // The authenticated user + User *User `json:"user"` +} + // Represents a feed subscription in the system type Feed struct { // Unique identifier for the feed @@ -43,3 +49,11 @@ type Mutation struct { // Root query type for reading data type Query struct { } + +// Represents a user in the system +type User struct { + // Unique identifier for the user + ID string `json:"id"` + // Username of the user + Username string `json:"username"` +} 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} } diff --git a/backend/middleware.go b/backend/middleware.go new file mode 100644 index 0000000..13234df --- /dev/null +++ b/backend/middleware.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/labstack/echo/v4" + "undef.ninja/x/feedaka/auth" + appcontext "undef.ninja/x/feedaka/context" +) + +// SessionAuthMiddleware validates session and adds user info to context +func SessionAuthMiddleware(sessionConfig *auth.SessionConfig) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Try to get user ID from session + userID, err := sessionConfig.GetUserID(c) + if err == nil { + // Add user ID to context + ctx := appcontext.SetUserID(c.Request().Context(), userID) + c.SetRequest(c.Request().WithContext(ctx)) + } + // If no valid session, continue without authentication + + return next(c) + } + } +} |
