aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/graphql
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-11-02 00:00:35 +0900
committernsfisis <nsfisis@gmail.com>2025-11-02 00:00:35 +0900
commit104341ddc4add57f83c58cb3fabb23b6fbfdd3e4 (patch)
tree862b109fe257e6170a88929729dae3bddfb6eb49 /backend/graphql
parentba1e0c904f810193f25d4f88cc2bb168f1d625fe (diff)
downloadfeedaka-feat/multi-user.tar.gz
feedaka-feat/multi-user.tar.zst
feedaka-feat/multi-user.zip
Diffstat (limited to 'backend/graphql')
-rw-r--r--backend/graphql/generated.go588
-rw-r--r--backend/graphql/model/generated.go14
-rw-r--r--backend/graphql/resolver/auth_helpers.go35
-rw-r--r--backend/graphql/resolver/resolver.go7
-rw-r--r--backend/graphql/resolver/schema.resolvers.go216
5 files changed, 847 insertions, 13 deletions
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} }