diff options
Diffstat (limited to 'backend/graphql')
| -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 | 29 | ||||
| -rw-r--r-- | backend/graphql/resolver/resolver.go | 7 | ||||
| -rw-r--r-- | backend/graphql/resolver/schema.resolvers.go | 244 |
5 files changed, 871 insertions, 11 deletions
diff --git a/backend/graphql/generated.go b/backend/graphql/generated.go index e4790bd..9f45445 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 @@ -76,11 +82,17 @@ type ComplexityRoot struct { Query struct { Article func(childComplexity int, id string) int + CurrentUser func(childComplexity int) int Feed func(childComplexity int, id string) int Feeds 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) + CurrentUser(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 @@ -293,6 +334,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.Article(childComplexity, args["id"].(string)), true + case "Query.currentUser": + if e.complexity.Query.CurrentUser == nil { + break + } + + return e.complexity.Query.CurrentUser(childComplexity), true + case "Query.feed": if e.complexity.Query.Feed == 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 + """ + currentUser: 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_currentUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_currentUser(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().CurrentUser(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_currentUser(_ 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 "currentUser": + 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_currentUser(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..dcc09fb --- /dev/null +++ b/backend/graphql/resolver/auth_helpers.go @@ -0,0 +1,29 @@ +package resolver + +import ( + "context" + "errors" + "fmt" + + "github.com/labstack/echo/v4" + 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 +} 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..46c39e7 100644 --- a/backend/graphql/resolver/schema.resolvers.go +++ b/backend/graphql/resolver/schema.resolvers.go @@ -12,6 +12,7 @@ import ( "time" "github.com/mmcdole/gofeed" + "undef.ninja/x/feedaka/auth" "undef.ninja/x/feedaka/db" gql "undef.ninja/x/feedaka/graphql" "undef.ninja/x/feedaka/graphql/model" @@ -19,6 +20,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 +37,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 +69,31 @@ 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) + // Fetch feed + feed, err := r.Queries.GetFeed(ctx, feedID) + if err != nil { + if err == sql.ErrNoRows { + return false, fmt.Errorf("feed not found") + } + return false, fmt.Errorf("failed to query feed: %w", err) + } + + // Check authorization + if feed.UserID != userID { + return false, fmt.Errorf("forbidden: you don't have access to this feed") + } + + err = r.Queries.UnsubscribeFeed(ctx, feed.ID) if err != nil { return false, fmt.Errorf("failed to unsubscribe from feed: %w", err) } @@ -78,15 +103,38 @@ 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) } + // Fetch article + article, err := r.Queries.GetArticle(ctx, articleID) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("article not found") + } + return nil, fmt.Errorf("failed to query article: %w", err) + } + + // Check authorization (article belongs to a feed owned by user) + feed, err := r.Queries.GetFeed(ctx, article.FeedID) + if err != nil { + return nil, fmt.Errorf("failed to query feed: %w", err) + } + if feed.UserID != userID { + return nil, fmt.Errorf("forbidden: you don't have access to this article") + } + // 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 +146,38 @@ 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) } + // Fetch article + article, err := r.Queries.GetArticle(ctx, articleID) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("article not found") + } + return nil, fmt.Errorf("failed to query article: %w", err) + } + + // Check authorization (article belongs to a feed owned by user) + feed, err := r.Queries.GetFeed(ctx, article.FeedID) + if err != nil { + return nil, fmt.Errorf("failed to query feed: %w", err) + } + if feed.UserID != userID { + return nil, fmt.Errorf("forbidden: you don't have access to this article") + } + // 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 +189,32 @@ 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) } + // Fetch feed + feed, err := r.Queries.GetFeed(ctx, feedID) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("feed not found") + } + return nil, fmt.Errorf("failed to query feed: %w", err) + } + + // Check authorization + if feed.UserID != userID { + return nil, fmt.Errorf("forbidden: you don't have access to this feed") + } + // 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 +225,32 @@ 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) } + // Fetch feed + feed, err := r.Queries.GetFeed(ctx, feedID) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("feed not found") + } + return nil, fmt.Errorf("failed to query feed: %w", err) + } + + // Check authorization + if feed.UserID != userID { + return nil, fmt.Errorf("forbidden: you don't have access to this feed") + } + // 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 +259,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 !auth.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 +338,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 +371,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,11 +404,17 @@ 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) } + // Fetch feed dbFeed, err := r.Queries.GetFeed(ctx, feedID) if err != nil { if err == sql.ErrNoRows { @@ -242,6 +423,11 @@ func (r *queryResolver) Feed(ctx context.Context, id string) (*model.Feed, error return nil, fmt.Errorf("failed to query feed: %w", err) } + // Check authorization + if dbFeed.UserID != userID { + return nil, fmt.Errorf("forbidden: you don't have access to this feed") + } + return &model.Feed{ ID: strconv.FormatInt(dbFeed.ID, 10), URL: dbFeed.Url, @@ -253,11 +439,17 @@ 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) } + // Fetch article row, err := r.Queries.GetArticle(ctx, articleID) if err != nil { if err == sql.ErrNoRows { @@ -266,6 +458,18 @@ func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article, return nil, fmt.Errorf("failed to query article: %w", err) } + // Check authorization (article's feed belongs to user) + // Note: GetArticle already joins with feeds table and returns feed info, + // but we need to check the user_id. Since GetArticleRow doesn't include user_id, + // we need to fetch the feed separately. + feed, err := r.Queries.GetFeed(ctx, row.FeedID) + if err != nil { + return nil, fmt.Errorf("failed to query feed: %w", err) + } + if feed.UserID != userID { + return nil, fmt.Errorf("forbidden: you don't have access to this article") + } + return &model.Article{ ID: strconv.FormatInt(row.ID, 10), FeedID: strconv.FormatInt(row.FeedID, 10), @@ -281,6 +485,28 @@ func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article, }, nil } +// CurrentUser is the resolver for the currentUser field. +func (r *queryResolver) CurrentUser(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} } |
